mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
feat: inbox (#1023)
* dev: initialize inbox * dev: inbox and inbox issues models, views and serializers * dev: issue object filter for inbox * dev: filter for search issues * dev: inbox snooze and duplicates * dev: set duplicate to null by default * feat: inbox ui and services * feat: project detail in inbox * style: layout, popover, icons, sidebar * dev: default inbox for project and pending issues count * dev: fix exception when creating default inbox * fix: empty state for inbox * dev: auto issue state updation when rejected or marked duplicate * fix: inbox update status * fix: hydrating chose with old values filters workflow * feat: inbox issue filtering * fix: issue inbox filtering * feat: filter inbox issues * refactor: analytics, border colors * dev: filters and views for inbox * dev: source for inboxissue and update list inbox issue * dev: update list endpoint to house filters and additional data * dev: bridge id for list * dev: remove print logs * dev: update inbox issue workflow * dev: add description_html in issue details * fix: inbox track event auth, chore: inbox issue action authorization * fix: removed unnecessary api calls * style: viewed issues * fix: priority validation * dev: remove print logs * dev: update issue inbox update workflow * chore: added inbox view context * fix: type errors * fix: build errors and warnings * dev: update issue inbox workflow and log all the changes * fix: filters logic, sidebar fields to show * dev: update issue filtering status * chore: update create inbox issue modal, fix: mutation issues * dev: update issue accept workflow * chore: add comment to inbox issues * chore: remove inboxIssueId from url after deleting * dev: update the issue triage workflow * fix: mutation after issue status change * chore: issue details sidebar divider * fix: issue activity for inbox issues * dev: update inbox perrmissions * dev: create new permission layer * chore: auth layer for inbox * chore: show accepting status * chore: show issue status at the top of issue details --------- Co-authored-by: Dakshesh Jain <dakshesh.jain14@gmail.com> Co-authored-by: gurusainath <gurusainath007@gmail.com> Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
parent
963ccd808d
commit
e9a0eb87cc
@ -1,2 +1,2 @@
|
||||
from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission
|
||||
from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission
|
||||
from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission
|
||||
from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission
|
||||
|
@ -89,3 +89,16 @@ class ProjectEntityPermission(BasePermission):
|
||||
role__in=[Admin, Member],
|
||||
project_id=view.project_id,
|
||||
).exists()
|
||||
|
||||
|
||||
class ProjectLitePermission(BasePermission):
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project_id=view.project_id,
|
||||
).exists()
|
@ -5,7 +5,6 @@ from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
from plane.db.models import WorkspaceMember
|
||||
|
||||
|
||||
|
||||
# Permission Mappings
|
||||
Owner = 20
|
||||
Admin = 15
|
||||
@ -44,7 +43,6 @@ class WorkSpaceBasePermission(BasePermission):
|
||||
|
||||
class WorkSpaceAdminPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
@ -53,3 +51,13 @@ class WorkSpaceAdminPermission(BasePermission):
|
||||
workspace__slug=view.workspace_slug,
|
||||
role__in=[Owner, Admin],
|
||||
).exists()
|
||||
|
||||
|
||||
class WorkspaceEntityPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=view.workspace_slug
|
||||
).exists()
|
||||
|
@ -69,6 +69,11 @@ from .importer import ImporterSerializer
|
||||
|
||||
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
|
||||
|
||||
from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer
|
||||
from .estimate import (
|
||||
EstimateSerializer,
|
||||
EstimatePointSerializer,
|
||||
EstimateReadSerializer,
|
||||
)
|
||||
|
||||
from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer
|
||||
from .analytic import AnalyticViewSerializer
|
||||
|
58
apiserver/plane/api/serializers/inbox.py
Normal file
58
apiserver/plane/api/serializers/inbox.py
Normal file
@ -0,0 +1,58 @@
|
||||
# Third party frameworks
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .issue import IssueFlatSerializer, LabelLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from .state import StateLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from plane.db.models import Inbox, InboxIssue, Issue
|
||||
|
||||
|
||||
class InboxSerializer(BaseSerializer):
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
pending_issue_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Inbox
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"project",
|
||||
"workspace",
|
||||
]
|
||||
|
||||
|
||||
class InboxIssueSerializer(BaseSerializer):
|
||||
issue_detail = IssueFlatSerializer(source="issue", read_only=True)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = InboxIssue
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"project",
|
||||
"workspace",
|
||||
]
|
||||
|
||||
|
||||
class InboxIssueLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = InboxIssue
|
||||
fields = ["id", "status", "duplicate_to", "snoozed_till", "source"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IssueStateInboxSerializer(BaseSerializer):
|
||||
state_detail = StateLiteSerializer(read_only=True, source="state")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
|
||||
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
bridge_id = serializers.UUIDField(read_only=True)
|
||||
issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = "__all__"
|
@ -41,6 +41,7 @@ class IssueFlatSerializer(BaseSerializer):
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"description_html",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
|
@ -141,6 +141,10 @@ from plane.api.views import (
|
||||
# Release Notes
|
||||
ReleaseNotesEndpoint,
|
||||
## End Release Notes
|
||||
# Inbox
|
||||
InboxViewSet,
|
||||
InboxIssueViewSet,
|
||||
## End Inbox
|
||||
# Analytics
|
||||
AnalyticsEndpoint,
|
||||
AnalyticViewViewset,
|
||||
@ -1244,6 +1248,50 @@ urlpatterns = [
|
||||
name="release-notes",
|
||||
),
|
||||
## End Release Notes
|
||||
# Inbox
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/",
|
||||
InboxViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="inbox",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:pk>/",
|
||||
InboxViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="inbox",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
|
||||
InboxIssueViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="inbox-issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
|
||||
InboxIssueViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="inbox-issue",
|
||||
),
|
||||
## End Inbox
|
||||
# Analytics
|
||||
path(
|
||||
"workspaces/<str:slug>/analytics/",
|
||||
|
@ -134,6 +134,7 @@ from .estimate import (
|
||||
|
||||
from .release import ReleaseNotesEndpoint
|
||||
|
||||
from .inbox import InboxViewSet, InboxIssueViewSet
|
||||
from .analytic import (
|
||||
AnalyticsEndpoint,
|
||||
AnalyticViewViewset,
|
||||
|
@ -40,7 +40,7 @@ class AnalyticsEndpoint(BaseAPIView):
|
||||
segment = request.GET.get("segment", False)
|
||||
filters = issue_filters(request.GET, "GET")
|
||||
|
||||
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
|
||||
queryset = Issue.issue_objects.filter(workspace__slug=slug, **filters)
|
||||
|
||||
total_issues = queryset.count()
|
||||
distribution = build_graph_plot(
|
||||
@ -79,7 +79,7 @@ class AnalyticsEndpoint(BaseAPIView):
|
||||
assignee_details = {}
|
||||
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
|
||||
assignee_details = (
|
||||
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
.order_by("assignees__id")
|
||||
.distinct("assignees__id")
|
||||
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
|
||||
@ -132,7 +132,7 @@ class SavedAnalyticEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
filter = analytic_view.query
|
||||
queryset = Issue.objects.filter(**filter)
|
||||
queryset = Issue.issue_objects.filter(**filter)
|
||||
|
||||
x_axis = analytic_view.query_dict.get("x_axis", False)
|
||||
y_axis = analytic_view.query_dict.get("y_axis", False)
|
||||
@ -209,7 +209,7 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
|
||||
try:
|
||||
filters = issue_filters(request.GET, "GET")
|
||||
|
||||
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
|
||||
queryset = Issue.issue_objects.filter(workspace__slug=slug, **filters)
|
||||
|
||||
total_issues = queryset.count()
|
||||
|
||||
|
@ -323,7 +323,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("issue_id"))
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@ -347,9 +347,9 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
group_by = request.GET.get("group_by", False)
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.objects.filter(issue_cycle__cycle_id=cycle_id)
|
||||
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
|
@ -241,7 +241,7 @@ class ImportServiceEndpoint(BaseAPIView):
|
||||
)
|
||||
# Delete all imported Issues
|
||||
imported_issues = importer.imported_data.get("issues", [])
|
||||
Issue.objects.filter(id__in=imported_issues).delete()
|
||||
Issue.issue_objects.filter(id__in=imported_issues).delete()
|
||||
|
||||
# Delete all imported Labels
|
||||
imported_labels = importer.imported_data.get("labels", [])
|
||||
|
349
apiserver/plane/api/views/inbox.py
Normal file
349
apiserver/plane/api/views/inbox.py
Normal file
@ -0,0 +1,349 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django import
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet
|
||||
from plane.api.permissions import ProjectBasePermission, ProjectLitePermission
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
Inbox,
|
||||
InboxIssue,
|
||||
Issue,
|
||||
State,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueActivity,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
IssueSerializer,
|
||||
InboxSerializer,
|
||||
InboxIssueSerializer,
|
||||
IssueCreateSerializer,
|
||||
IssueStateInboxSerializer,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
|
||||
|
||||
class InboxViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
serializer_class = InboxSerializer
|
||||
model = Inbox
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
.annotate(
|
||||
pending_issue_count=Count(
|
||||
"issue_inbox",
|
||||
filter=Q(issue_inbox__status=-2),
|
||||
)
|
||||
)
|
||||
.select_related("workspace", "project")
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
try:
|
||||
inbox = Inbox.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
|
||||
if inbox.is_default:
|
||||
return Response(
|
||||
{"error": "You cannot delete the default inbox"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
inbox.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wronf please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class InboxIssueViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
|
||||
serializer_class = InboxIssueSerializer
|
||||
model = InboxIssue
|
||||
|
||||
filterset_fields = [
|
||||
"status",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
inbox_id=self.kwargs.get("inbox_id"),
|
||||
)
|
||||
.select_related("issue", "workspace", "project")
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id, inbox_id):
|
||||
try:
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.objects.filter(
|
||||
issue_inbox__inbox_id=inbox_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(bridge_id=F("issue_inbox__id"))
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.order_by(order_by)
|
||||
.filter(**filters)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_inbox",
|
||||
queryset=InboxIssue.objects.only(
|
||||
"status", "duplicate_to", "snoozed_till", "source"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
issues_data = IssueStateInboxSerializer(issues, many=True).data
|
||||
return Response(
|
||||
issues_data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, inbox_id):
|
||||
try:
|
||||
if not request.data.get("issue", {}).get("name", False):
|
||||
return Response(
|
||||
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not request.data.get("issue", {}).get("priority", "low") in [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"urgent",
|
||||
None,
|
||||
]:
|
||||
return Response(
|
||||
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Create or get state
|
||||
state, _ = State.objects.get_or_create(
|
||||
name="Triage",
|
||||
group="backlog",
|
||||
description="Default state for managing all Inbox Issues",
|
||||
project_id=project_id,
|
||||
color="#ff7700",
|
||||
)
|
||||
|
||||
# create an issue
|
||||
issue = Issue.objects.create(
|
||||
name=request.data.get("issue", {}).get("name"),
|
||||
description=request.data.get("issue", {}).get("description", {}),
|
||||
description_html=request.data.get("issue", {}).get(
|
||||
"description_html", "<p></p>"
|
||||
),
|
||||
priority=request.data.get("issue", {}).get("priority", "low"),
|
||||
project_id=project_id,
|
||||
state=state,
|
||||
)
|
||||
|
||||
# Create an Issue Activity
|
||||
# Track the issue
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
)
|
||||
# create an inbox issue
|
||||
InboxIssue.objects.create(
|
||||
inbox_id=inbox_id,
|
||||
project_id=project_id,
|
||||
issue=issue,
|
||||
source=request.data.get("source", "in-app"),
|
||||
)
|
||||
|
||||
serializer = IssueStateInboxSerializer(issue)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, inbox_id, pk):
|
||||
try:
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
|
||||
)
|
||||
|
||||
issue_data = request.data.pop("issue", False)
|
||||
|
||||
if bool(issue_data):
|
||||
issue = Issue.objects.get(
|
||||
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
issue_serializer = IssueCreateSerializer(
|
||||
issue, data=issue_data, partial=True
|
||||
)
|
||||
|
||||
if issue_serializer.is_valid():
|
||||
current_instance = issue
|
||||
issue_serializer.save()
|
||||
# Log all the updates
|
||||
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
|
||||
if issue is not None:
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
serializer = InboxIssueSerializer(
|
||||
inbox_issue, data=request.data, partial=True
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# Update the issue state if the issue is rejected or marked as duplicate
|
||||
if serializer.data["status"] in [-1, 2]:
|
||||
issue = Issue.objects.get(
|
||||
pk=inbox_issue.issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
state = State.objects.filter(
|
||||
group="cancelled", workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
|
||||
# Update the issue state if it is accepted
|
||||
if serializer.data["status"] in [1]:
|
||||
issue = Issue.objects.get(
|
||||
pk=inbox_issue.issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# Update the issue state only if it is in triage state
|
||||
if issue.state.name == "Triage":
|
||||
# Move to default state
|
||||
state = State.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, default=True
|
||||
).first()
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except InboxIssue.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Inbox Issue does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def retrieve(self, request, slug, project_id, inbox_id, pk):
|
||||
try:
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
|
||||
)
|
||||
issue = Issue.objects.get(
|
||||
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = IssueStateInboxSerializer(issue)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
@ -132,10 +132,8 @@ class IssueViewSet(BaseViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@ -248,7 +246,7 @@ class IssueViewSet(BaseViewSet):
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
try:
|
||||
issue = Issue.objects.get(
|
||||
issue = Issue.issue_objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
@ -263,9 +261,9 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
issues = (
|
||||
Issue.objects.filter(assignees__in=[request.user], workspace__slug=slug)
|
||||
Issue.issue_objects.filter(assignees__in=[request.user], workspace__slug=slug)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@ -311,7 +309,7 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
issues = (
|
||||
Issue.objects.filter(workspace__slug=slug)
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.order_by("-created_at")
|
||||
)
|
||||
@ -581,7 +579,7 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
issues = Issue.objects.filter(
|
||||
issues = Issue.issue_objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
|
||||
)
|
||||
|
||||
@ -610,7 +608,7 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
sub_issues = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
parent_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
.select_related("project")
|
||||
@ -656,7 +654,7 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
# Assign multiple sub issues
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
parent_issue = Issue.objects.get(pk=issue_id)
|
||||
parent_issue = Issue.issue_objects.get(pk=issue_id)
|
||||
sub_issue_ids = request.data.get("sub_issue_ids", [])
|
||||
|
||||
if not len(sub_issue_ids):
|
||||
@ -665,14 +663,14 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
sub_issues = Issue.objects.filter(id__in=sub_issue_ids)
|
||||
sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids)
|
||||
|
||||
for sub_issue in sub_issues:
|
||||
sub_issue.parent = parent_issue
|
||||
|
||||
_ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10)
|
||||
|
||||
updated_sub_issues = Issue.objects.filter(id__in=sub_issue_ids)
|
||||
updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids)
|
||||
|
||||
return Response(
|
||||
IssueFlatSerializer(updated_sub_issues, many=True).data,
|
||||
|
@ -201,7 +201,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("issue"))
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@ -226,9 +226,9 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
group_by = request.GET.get("group_by", False)
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.objects.filter(issue_module__module_id=module_id)
|
||||
Issue.issue_objects.filter(issue_module__module_id=module_id)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
|
@ -37,7 +37,7 @@ class UserEndpoint(BaseViewSet):
|
||||
workspace_invites = WorkspaceMemberInvite.objects.filter(
|
||||
email=request.user.email
|
||||
).count()
|
||||
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
|
||||
assigned_issues = Issue.issue_objects.filter(assignees__in=[request.user]).count()
|
||||
|
||||
serialized_data = UserSerializer(request.user).data
|
||||
serialized_data["workspace"] = {
|
||||
@ -59,7 +59,7 @@ class UserEndpoint(BaseViewSet):
|
||||
workspace_invites = WorkspaceMemberInvite.objects.filter(
|
||||
email=request.user.email
|
||||
).count()
|
||||
assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count()
|
||||
assigned_issues = Issue.issue_objects.filter(assignees__in=[request.user]).count()
|
||||
|
||||
fallback_workspace = Workspace.objects.filter(
|
||||
workspace_member__member=request.user
|
||||
|
@ -47,9 +47,9 @@ from plane.db.models import (
|
||||
Page,
|
||||
IssueAssignee,
|
||||
ModuleMember,
|
||||
Inbox,
|
||||
)
|
||||
|
||||
|
||||
from plane.bgtasks.project_invitation_task import project_invitation
|
||||
|
||||
|
||||
@ -248,6 +248,20 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
if serializer.data["inbox_view"]:
|
||||
Inbox.objects.get_or_create(
|
||||
name=f"{project.name} Inbox", project=project, is_default=True
|
||||
)
|
||||
|
||||
# Create the triage state in Backlog group
|
||||
State.objects.get_or_create(
|
||||
name="Triage",
|
||||
group="backlog",
|
||||
description="Default state for managing all Inbox Issues",
|
||||
project_id=pk,
|
||||
color="#ff7700"
|
||||
)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@ -477,7 +491,9 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
)
|
||||
if requesting_project_member.role < project_member.role:
|
||||
return Response(
|
||||
{"error": "You cannot remove a user having role higher than yourself"},
|
||||
{
|
||||
"error": "You cannot remove a user having role higher than yourself"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
@ -57,7 +57,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
else:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
return (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
@ -210,7 +210,7 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
blocker_blocked_by = request.query_params.get("blocker_blocked_by", False)
|
||||
issue_id = request.query_params.get("issue_id", False)
|
||||
|
||||
issues = Issue.objects.filter(
|
||||
issues = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
@ -220,16 +220,16 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
issues = search_issues(query, issues)
|
||||
|
||||
if parent == "true" and issue_id:
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True
|
||||
).exclude(
|
||||
pk__in=Issue.objects.filter(parent__isnull=False).values_list(
|
||||
pk__in=Issue.issue_objects.filter(parent__isnull=False).values_list(
|
||||
"parent_id", flat=True
|
||||
)
|
||||
)
|
||||
if blocker_blocked_by == "true" and issue_id:
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
issue = Issue.issue_objects.get(pk=issue_id)
|
||||
issues = issues.filter(
|
||||
~Q(pk=issue_id),
|
||||
~Q(blocked_issues__block=issue),
|
||||
|
@ -89,7 +89,7 @@ class StateViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Check for any issues in the state
|
||||
issue_exist = Issue.objects.filter(state=pk).exists()
|
||||
issue_exist = Issue.issue_objects.filter(state=pk).exists()
|
||||
|
||||
if issue_exist:
|
||||
return Response(
|
||||
|
@ -67,7 +67,7 @@ class ViewIssuesEndpoint(BaseAPIView):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
issues = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
**queries, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
.filter(**filters)
|
||||
|
@ -755,7 +755,7 @@ class UserIssueCompletedGraphEndpoint(BaseAPIView):
|
||||
month = request.GET.get("month", 1)
|
||||
|
||||
issues = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
assignees__in=[request.user],
|
||||
workspace__slug=slug,
|
||||
completed_at__month=month,
|
||||
@ -800,7 +800,7 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
month = request.GET.get("month", 1)
|
||||
|
||||
completed_issues = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
assignees__in=[request.user],
|
||||
workspace__slug=slug,
|
||||
completed_at__month=month,
|
||||
@ -813,24 +813,24 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
.order_by("week_in_month")
|
||||
)
|
||||
|
||||
assigned_issues = Issue.objects.filter(
|
||||
assigned_issues = Issue.issue_objects.filter(
|
||||
workspace__slug=slug, assignees__in=[request.user]
|
||||
).count()
|
||||
|
||||
pending_issues_count = Issue.objects.filter(
|
||||
pending_issues_count = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
).count()
|
||||
|
||||
completed_issues_count = Issue.objects.filter(
|
||||
completed_issues_count = Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
state__group="completed",
|
||||
).count()
|
||||
|
||||
issues_due_week = (
|
||||
Issue.objects.filter(
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
@ -840,14 +840,14 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
state_distribution = (
|
||||
Issue.objects.filter(workspace__slug=slug, assignees__in=[request.user])
|
||||
Issue.issue_objects.filter(workspace__slug=slug, assignees__in=[request.user])
|
||||
.annotate(state_group=F("state__group"))
|
||||
.values("state_group")
|
||||
.annotate(state_count=Count("state_group"))
|
||||
.order_by("state_group")
|
||||
)
|
||||
|
||||
overdue_issues = Issue.objects.filter(
|
||||
overdue_issues = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
@ -855,7 +855,7 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
completed_at__isnull=True,
|
||||
).values("id", "name", "workspace__slug", "project_id", "target_date")
|
||||
|
||||
upcoming_issues = Issue.objects.filter(
|
||||
upcoming_issues = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
target_date__gte=timezone.now(),
|
||||
workspace__slug=slug,
|
||||
|
@ -36,7 +36,7 @@ row_mapping = {
|
||||
def analytic_export_task(email, data, slug):
|
||||
try:
|
||||
filters = issue_filters(data, "POST")
|
||||
queryset = Issue.objects.filter(**filters, workspace__slug=slug)
|
||||
queryset = Issue.issue_objects.filter(**filters, workspace__slug=slug)
|
||||
|
||||
x_axis = data.get("x_axis", False)
|
||||
y_axis = data.get("y_axis", False)
|
||||
@ -53,7 +53,7 @@ def analytic_export_task(email, data, slug):
|
||||
assignee_details = {}
|
||||
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
|
||||
assignee_details = (
|
||||
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
.order_by("assignees__id")
|
||||
.distinct("assignees__id")
|
||||
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
|
||||
|
@ -44,7 +44,7 @@ def track_name(
|
||||
field="name",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} updated the start date to {requested_data.get('name')}",
|
||||
comment=f"{actor.email} updated the name to {requested_data.get('name')}",
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -68,4 +68,5 @@ from .page import Page, PageBlock, PageFavorite, PageLabel
|
||||
|
||||
from .estimate import Estimate, EstimatePoint
|
||||
|
||||
from .analytic import AnalyticView
|
||||
from .inbox import Inbox, InboxIssue
|
||||
from .analytic import AnalyticView
|
||||
|
51
apiserver/plane/db/models/inbox.py
Normal file
51
apiserver/plane/db/models/inbox.py
Normal file
@ -0,0 +1,51 @@
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import ProjectBaseModel
|
||||
|
||||
|
||||
class Inbox(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(verbose_name="Inbox Description", blank=True)
|
||||
is_default = models.BooleanField(default=False)
|
||||
view_props = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the Inbox"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project"]
|
||||
verbose_name = "Inbox"
|
||||
verbose_name_plural = "Inboxes"
|
||||
db_table = "inboxes"
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
class InboxIssue(ProjectBaseModel):
|
||||
inbox = models.ForeignKey(
|
||||
"db.Inbox", related_name="issue_inbox", on_delete=models.CASCADE
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", related_name="issue_inbox", on_delete=models.CASCADE
|
||||
)
|
||||
status = models.IntegerField(
|
||||
choices=((-2, "Pending"), (-1, "Rejected"), (0, "Snoozed"), (1, "Accepted"), (2, "Duplicate")),
|
||||
default=-2,
|
||||
)
|
||||
snoozed_till = models.DateTimeField(null=True)
|
||||
duplicate_to = models.ForeignKey(
|
||||
"db.Issue", related_name="inbox_duplicate", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
source = models.TextField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "InboxIssue"
|
||||
verbose_name_plural = "InboxIssues"
|
||||
db_table = "inbox_issues"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the Issue"""
|
||||
return f"{self.issue.name} <{self.inbox.name}>"
|
@ -17,6 +17,20 @@ from plane.utils.html_processor import strip_tags
|
||||
|
||||
|
||||
# TODO: Handle identifiers for Bulk Inserts - nk
|
||||
class IssueManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
models.Q(issue_inbox__status=1)
|
||||
| models.Q(issue_inbox__status=-1)
|
||||
| models.Q(issue_inbox__status=2)
|
||||
| models.Q(issue_inbox__isnull=True)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Issue(ProjectBaseModel):
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
@ -68,6 +82,9 @@ class Issue(ProjectBaseModel):
|
||||
sort_order = models.FloatField(default=65535)
|
||||
completed_at = models.DateTimeField(null=True)
|
||||
|
||||
objects = models.Manager()
|
||||
issue_objects = IssueManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue"
|
||||
verbose_name_plural = "Issues"
|
||||
|
@ -69,6 +69,7 @@ class Project(BaseModel):
|
||||
cycle_view = models.BooleanField(default=True)
|
||||
issue_views_view = models.BooleanField(default=True)
|
||||
page_view = models.BooleanField(default=True)
|
||||
inbox_view = models.BooleanField(default=False)
|
||||
cover_image = models.URLField(blank=True, null=True, max_length=800)
|
||||
estimate = models.ForeignKey(
|
||||
"db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True
|
||||
|
@ -231,6 +231,17 @@ def filter_module(params, filter, method):
|
||||
return filter
|
||||
|
||||
|
||||
def filter_inbox_status(params, filter, method):
|
||||
if method == "GET":
|
||||
status = params.get("inbox_status").split(",")
|
||||
if len(status) and "" not in status:
|
||||
filter["issue_inbox__status__in"] = status
|
||||
else:
|
||||
if params.get("inbox_status", None) and len(params.get("inbox_status")):
|
||||
filter["issue_inbox__status__in"] = params.get("inbox_status")
|
||||
return filter
|
||||
|
||||
|
||||
def issue_filters(query_params, method):
|
||||
filter = dict()
|
||||
|
||||
@ -252,6 +263,7 @@ def issue_filters(query_params, method):
|
||||
"project": filter_project,
|
||||
"cycle": filter_cycle,
|
||||
"module": filter_module,
|
||||
"inbox_status": filter_inbox_status
|
||||
}
|
||||
|
||||
for key, value in ISSUE_FILTER.items():
|
||||
|
@ -102,7 +102,7 @@ export const CommandPalette: React.FC = () => {
|
||||
const page = pages[pages.length - 1];
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
const { workspaceSlug, projectId, issueId, inboxId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
@ -145,7 +145,7 @@ export const CommandPalette: React.FC = () => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, issueId, projectId]
|
||||
[workspaceSlug, issueId, projectId, user]
|
||||
);
|
||||
|
||||
const handleIssueAssignees = (assignee: string) => {
|
||||
@ -372,6 +372,7 @@ export const CommandPalette: React.FC = () => {
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isIssueModalOpen}
|
||||
handleClose={() => setIsIssueModalOpen(false)}
|
||||
fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]}
|
||||
/>
|
||||
<BulkDeleteIssuesModal
|
||||
isOpen={isBulkDeleteIssuesModalOpen}
|
||||
|
@ -186,7 +186,18 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
console.log(error);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, moduleId, groupTitle, index, selectedGroup, orderBy, params]
|
||||
[
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
moduleId,
|
||||
groupTitle,
|
||||
index,
|
||||
selectedGroup,
|
||||
orderBy,
|
||||
params,
|
||||
user,
|
||||
]
|
||||
);
|
||||
|
||||
const getStyle = (
|
||||
|
@ -105,7 +105,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
console.log(error);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, moduleId, params]
|
||||
[workspaceSlug, projectId, cycleId, moduleId, viewId, params, user]
|
||||
);
|
||||
|
||||
const handleCopyText = () => {
|
||||
|
@ -276,6 +276,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
handleDeleteIssue,
|
||||
params,
|
||||
states,
|
||||
user,
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -154,7 +154,18 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, moduleId, groupTitle, index, selectedGroup, orderBy, params]
|
||||
[
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
moduleId,
|
||||
groupTitle,
|
||||
index,
|
||||
selectedGroup,
|
||||
orderBy,
|
||||
params,
|
||||
user,
|
||||
]
|
||||
);
|
||||
|
||||
const handleCopyText = () => {
|
||||
|
24
apps/app/components/icons/inbox-icon.tsx
Normal file
24
apps/app/components/icons/inbox-icon.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const InboxIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
color = "#858E96",
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M1.75 15.5C1.41667 15.5 1.125 15.375 0.875 15.125C0.625 14.875 0.5 14.5833 0.5 14.25V1.75C0.5 1.41667 0.625 1.125 0.875 0.875C1.125 0.625 1.41667 0.5 1.75 0.5H14.25C14.5833 0.5 14.875 0.625 15.125 0.875C15.375 1.125 15.5 1.41667 15.5 1.75V14.25C15.5 14.5833 15.375 14.875 15.125 15.125C14.875 15.375 14.5833 15.5 14.25 15.5H1.75ZM1.75 14.25H14.25V11.4167H11.2083C10.8472 11.9722 10.3785 12.3993 9.80208 12.6979C9.22569 12.9965 8.625 13.1458 8 13.1458C7.375 13.1458 6.77431 12.9965 6.19792 12.6979C5.62153 12.3993 5.15278 11.9722 4.79167 11.4167H1.75V14.25ZM8.00035 11.8958C8.48623 11.8958 8.93403 11.7743 9.34375 11.5312C9.75347 11.2882 10.0764 10.9514 10.3125 10.5208C10.4097 10.3819 10.5382 10.2882 10.6979 10.2396C10.8576 10.191 11.0208 10.1667 11.1875 10.1667H14.25V1.75H1.75V10.1667H4.8125C4.97917 10.1667 5.14236 10.191 5.30208 10.2396C5.46181 10.2882 5.59028 10.3819 5.6875 10.5208C5.92361 10.9514 6.24665 11.2882 6.6566 11.5312C7.06656 11.7743 7.51448 11.8958 8.00035 11.8958Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
@ -78,3 +78,5 @@ export * from "./video-file-icon";
|
||||
export * from "./audio-file-icon";
|
||||
export * from "./command-icon";
|
||||
export * from "./color-picker-icon";
|
||||
export * from "./inbox-icon";
|
||||
export * from "./stacked-layers-horizontal-icon";
|
||||
|
24
apps/app/components/icons/stacked-layers-horizontal-icon.tsx
Normal file
24
apps/app/components/icons/stacked-layers-horizontal-icon.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const StackedLayersHorizontalIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
className,
|
||||
color = "#858e96",
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 17 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.16567 12.75C3.849 12.75 3.56862 12.6312 3.32452 12.3937C3.08042 12.1562 2.95837 11.8792 2.95837 11.5625V4.81354C2.95837 4.64531 3.0156 4.5043 3.13007 4.39049C3.24454 4.27669 3.38638 4.21979 3.55559 4.21979C3.72481 4.21979 3.86549 4.27669 3.97764 4.39049C4.0898 4.5043 4.14587 4.64531 4.14587 4.81354V11.5625H12.5177C12.686 11.5625 12.827 11.6197 12.9408 11.7342C13.0546 11.8487 13.1115 11.9905 13.1115 12.1597C13.1115 12.3289 13.0546 12.4696 12.9408 12.5818C12.827 12.6939 12.686 12.75 12.5177 12.75H4.16567ZM6.52087 10.375C6.20421 10.375 5.92712 10.2562 5.68962 10.0187C5.45212 9.78125 5.33337 9.50417 5.33337 9.1875V2.0625C5.33337 1.74583 5.45212 1.46875 5.68962 1.23125C5.92712 0.99375 6.20421 0.875 6.52087 0.875H15.2292C15.5459 0.875 15.823 0.99375 16.0605 1.23125C16.298 1.46875 16.4167 1.74583 16.4167 2.0625V9.1875C16.4167 9.50417 16.298 9.78125 16.0605 10.0187C15.823 10.2562 15.5459 10.375 15.2292 10.375H6.52087ZM6.52087 9.1875H15.2292V3.28958H6.52087V9.1875ZM1.77087 15.125C1.45421 15.125 1.17712 15.0062 0.939624 14.7687C0.702124 14.5312 0.583374 14.2542 0.583374 13.9375V7.18854C0.583374 7.02031 0.640605 6.8793 0.755067 6.76549C0.869542 6.65169 1.01138 6.59479 1.18059 6.59479C1.34981 6.59479 1.49049 6.65169 1.60264 6.76549C1.7148 6.8793 1.77087 7.02031 1.77087 7.18854V13.9375H10.123C10.2912 13.9375 10.4322 13.9947 10.546 14.1092C10.6598 14.2237 10.7167 14.3655 10.7167 14.5347C10.7167 14.7039 10.6598 14.8446 10.546 14.9568C10.4322 15.0689 10.2912 15.125 10.123 15.125H1.77087Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
164
apps/app/components/inbox/decline-issue-modal.tsx
Normal file
164
apps/app/components/inbox/decline-issue-modal.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import inboxServices from "services/inbox.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
import useUser from "hooks/use-user";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { SecondaryButton, DangerButton } from "components/ui";
|
||||
// types
|
||||
import type { IInboxIssue, ICurrentUserResponse, IInboxIssueDetail } from "types";
|
||||
// fetch-keys
|
||||
import { INBOX_ISSUES, INBOX_ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data: IInboxIssue | undefined;
|
||||
};
|
||||
|
||||
export const DeclineIssueModal: React.FC<Props> = ({ isOpen, handleClose, data }) => {
|
||||
const [isDeclining, setIsDeclining] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, inboxId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
const { params } = useInboxView();
|
||||
|
||||
const onClose = () => {
|
||||
setIsDeclining(false);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
if (!workspaceSlug || !projectId || !inboxId || !data) return;
|
||||
|
||||
setIsDeclining(true);
|
||||
|
||||
inboxServices
|
||||
.markInboxStatus(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString(),
|
||||
data.bridge_id,
|
||||
{
|
||||
status: -1,
|
||||
},
|
||||
user
|
||||
)
|
||||
.then(() => {
|
||||
mutate<IInboxIssueDetail>(
|
||||
INBOX_ISSUE_DETAILS(inboxId.toString(), data.bridge_id),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
issue_inbox: [{ ...prevData.issue_inbox[0], status: -1 }],
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
mutate<IInboxIssue[]>(
|
||||
INBOX_ISSUES(inboxId.toString(), params),
|
||||
(prevData) =>
|
||||
prevData?.map((i) =>
|
||||
i.bridge_id === data.bridge_id
|
||||
? { ...i, issue_inbox: [{ ...i.issue_inbox[0], status: -1 }] }
|
||||
: i
|
||||
),
|
||||
false
|
||||
);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue declined successfully.",
|
||||
});
|
||||
onClose();
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be declined. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsDeclining(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-brand-base bg-brand-base text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="flex w-full items-center justify-start gap-6">
|
||||
<span className="place-items-center rounded-full bg-red-500/20 p-4">
|
||||
<ExclamationTriangleIcon
|
||||
className="h-6 w-6 text-red-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="flex items-center justify-start">
|
||||
<h3 className="text-xl font-medium 2xl:text-2xl">Decline Issue</h3>
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
<p className="text-sm text-brand-secondary">
|
||||
Are you sure you want to decline issue{" "}
|
||||
<span className="break-all font-medium text-brand-base">
|
||||
{data?.project_detail?.identifier}-{data?.sequence_id}
|
||||
</span>
|
||||
{""}? This action cannot be undone.
|
||||
</p>
|
||||
</span>
|
||||
<div className="flex justify-end gap-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<DangerButton onClick={handleDecline} loading={isDeclining}>
|
||||
{isDeclining ? "Declining..." : "Decline Issue"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
150
apps/app/components/inbox/delete-issue-modal.tsx
Normal file
150
apps/app/components/inbox/delete-issue-modal.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import inboxServices from "services/inbox.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
import useUser from "hooks/use-user";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { SecondaryButton, DangerButton } from "components/ui";
|
||||
// types
|
||||
import type { IInboxIssue } from "types";
|
||||
// fetch-keys
|
||||
import { INBOX_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data: IInboxIssue | undefined;
|
||||
};
|
||||
|
||||
export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data }) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, inboxId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
const { params } = useInboxView();
|
||||
|
||||
const onClose = () => {
|
||||
setIsDeleting(false);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!workspaceSlug || !projectId || !inboxId || !data) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
inboxServices
|
||||
.deleteInboxIssue(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString(),
|
||||
data.bridge_id.toString(),
|
||||
user
|
||||
)
|
||||
.then(() => {
|
||||
mutate<IInboxIssue[]>(
|
||||
INBOX_ISSUES(inboxId.toString(), params),
|
||||
(prevData) => (prevData ?? []).filter((i) => i.id !== data.id),
|
||||
false
|
||||
);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue deleted successfully.",
|
||||
});
|
||||
|
||||
// remove inboxIssueId from the url
|
||||
router.push({
|
||||
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
|
||||
});
|
||||
|
||||
onClose();
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be deleted. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsDeleting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-brand-base bg-brand-base text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="flex w-full items-center justify-start gap-6">
|
||||
<span className="place-items-center rounded-full bg-red-500/20 p-4">
|
||||
<ExclamationTriangleIcon
|
||||
className="h-6 w-6 text-red-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="flex items-center justify-start">
|
||||
<h3 className="text-xl font-medium 2xl:text-2xl">Delete Issue</h3>
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
<p className="text-sm text-brand-secondary">
|
||||
Are you sure you want to delete issue{" "}
|
||||
<span className="break-all font-medium text-brand-base">
|
||||
{data?.project_detail?.identifier}-{data?.sequence_id}
|
||||
</span>
|
||||
{""}? This action cannot be undone.
|
||||
</p>
|
||||
</span>
|
||||
<div className="flex justify-end gap-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<DangerButton onClick={handleDelete} loading={isDeleting}>
|
||||
{isDeleting ? "Deleting..." : "Delete Issue"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
63
apps/app/components/inbox/filters-dropdown.tsx
Normal file
63
apps/app/components/inbox/filters-dropdown.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
// ui
|
||||
import { MultiLevelDropdown } from "components/ui";
|
||||
// icons
|
||||
import { getPriorityIcon } from "components/icons";
|
||||
// types
|
||||
import { IInboxFilterOptions } from "types";
|
||||
// constants
|
||||
import { PRIORITIES } from "constants/project";
|
||||
import { STATUS } from "constants/inbox";
|
||||
|
||||
type Props = {
|
||||
filters: Partial<IInboxFilterOptions>;
|
||||
onSelect: (option: any) => void;
|
||||
direction?: "left" | "right";
|
||||
height?: "sm" | "md" | "rg" | "lg";
|
||||
};
|
||||
|
||||
export const FiltersDropdown: React.FC<Props> = ({ filters, onSelect, direction, height }) => (
|
||||
<MultiLevelDropdown
|
||||
label="Filters"
|
||||
onSelect={onSelect}
|
||||
direction={direction}
|
||||
height={height}
|
||||
options={[
|
||||
{
|
||||
id: "priority",
|
||||
label: "Priority",
|
||||
value: PRIORITIES,
|
||||
children: [
|
||||
...PRIORITIES.map((priority) => ({
|
||||
id: priority ?? "none",
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
{getPriorityIcon(priority)} {priority ?? "None"}
|
||||
</div>
|
||||
),
|
||||
value: {
|
||||
key: "priority",
|
||||
value: priority,
|
||||
},
|
||||
selected: filters?.priority?.includes(priority ?? "none"),
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "inbox_status",
|
||||
label: "Status",
|
||||
value: Object.values(STATUS),
|
||||
children: [
|
||||
...Object.keys(STATUS).map((status) => ({
|
||||
id: status,
|
||||
label: status,
|
||||
value: {
|
||||
key: "inbox_status",
|
||||
value: STATUS[status],
|
||||
},
|
||||
selected: filters?.inbox_status?.includes(STATUS[status]),
|
||||
})),
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
226
apps/app/components/inbox/inbox-action-headers.tsx
Normal file
226
apps/app/components/inbox/inbox-action-headers.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-datepicker
|
||||
import DatePicker from "react-datepicker";
|
||||
// headless ui
|
||||
import { Popover } from "@headlessui/react";
|
||||
// contexts
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
// hooks
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// components
|
||||
import { FiltersDropdown } from "components/inbox";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { InboxIcon, StackedLayersHorizontalIcon } from "components/icons";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IInboxIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
issueCount: number;
|
||||
currentIssueIndex: number;
|
||||
issue?: IInboxIssue;
|
||||
onAccept: () => Promise<void>;
|
||||
onDecline: () => void;
|
||||
onMarkAsDuplicate: () => void;
|
||||
onSnooze: (date: Date | string) => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export const InboxActionHeader: React.FC<Props> = (props) => {
|
||||
const {
|
||||
issueCount,
|
||||
currentIssueIndex,
|
||||
onAccept,
|
||||
onDecline,
|
||||
onMarkAsDuplicate,
|
||||
onSnooze,
|
||||
onDelete,
|
||||
issue,
|
||||
} = props;
|
||||
|
||||
const [isAccepting, setIsAccepting] = useState(false);
|
||||
const [date, setDate] = useState(new Date());
|
||||
|
||||
const router = useRouter();
|
||||
const { inboxIssueId } = router.query;
|
||||
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
const { filters, setFilters, filtersLength } = useInboxView();
|
||||
const { user } = useUserAuth();
|
||||
|
||||
const handleAcceptIssue = () => {
|
||||
setIsAccepting(true);
|
||||
|
||||
onAccept().finally(() => setIsAccepting(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!issue?.issue_inbox[0].snoozed_till) return;
|
||||
|
||||
setDate(new Date(issue.issue_inbox[0].snoozed_till));
|
||||
}, [issue]);
|
||||
|
||||
const issueStatus = issue?.issue_inbox[0].status;
|
||||
const isAllowed = memberRole.isMember || memberRole.isOwner;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 border-b border-brand-base divide-x divide-brand-base">
|
||||
<div className="col-span-1 flex justify-between p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<InboxIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<h3 className="font-medium">Inbox</h3>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<FiltersDropdown
|
||||
filters={filters}
|
||||
onSelect={(option) => {
|
||||
const key = option.key as keyof typeof filters;
|
||||
|
||||
const valueExists = (filters[key] as any[])?.includes(option.value);
|
||||
|
||||
if (valueExists) {
|
||||
setFilters({
|
||||
[option.key]: ((filters[key] ?? []) as any[])?.filter(
|
||||
(val) => val !== option.value
|
||||
),
|
||||
});
|
||||
} else {
|
||||
setFilters({
|
||||
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
|
||||
});
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
height="rg"
|
||||
/>
|
||||
{filtersLength > 0 && (
|
||||
<div className="absolute -top-2 -right-2 h-4 w-4 text-[0.65rem] grid place-items-center rounded-full text-brand-base bg-brand-surface-2 border border-brand-base z-10">
|
||||
<span>{filtersLength}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{inboxIssueId && (
|
||||
<div className="flex justify-between items-center gap-4 px-4 col-span-3">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-brand-base bg-brand-surface-1 p-1.5 hover:bg-brand-surface-2"
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "ArrowUp" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<ChevronUpIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-brand-base bg-brand-surface-1 p-1.5 hover:bg-brand-surface-2"
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "ArrowDown" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="text-sm">
|
||||
{currentIssueIndex + 1}/{issueCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{isAllowed && (
|
||||
<div className={`flex gap-3 flex-wrap ${issueStatus !== -2 ? "opacity-70" : ""}`}>
|
||||
<Popover className="relative">
|
||||
<Popover.Button as="button" type="button" disabled={issueStatus !== -2}>
|
||||
<SecondaryButton
|
||||
className={`flex gap-x-1 items-center ${
|
||||
issueStatus !== -2 ? "cursor-not-allowed" : ""
|
||||
}`}
|
||||
size="sm"
|
||||
>
|
||||
<ClockIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<span>Snooze</span>
|
||||
</SecondaryButton>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="w-80 p-2 absolute right-0 z-10 mt-2 rounded-md border border-brand-base bg-brand-surface-2 shadow-lg">
|
||||
{({ close }) => (
|
||||
<div className="w-full h-full flex flex-col gap-y-1">
|
||||
<DatePicker
|
||||
selected={date ? new Date(date) : null}
|
||||
onChange={(val) => {
|
||||
if (!val) return;
|
||||
setDate(val);
|
||||
}}
|
||||
dateFormat="dd-MM-yyyy"
|
||||
inline
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
close();
|
||||
onSnooze(date);
|
||||
}}
|
||||
>
|
||||
Snooze
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
<SecondaryButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
onClick={onMarkAsDuplicate}
|
||||
disabled={issueStatus !== -2}
|
||||
>
|
||||
<StackedLayersHorizontalIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<span>Mark as duplicate</span>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
onClick={handleAcceptIssue}
|
||||
disabled={issueStatus !== -2}
|
||||
loading={isAccepting}
|
||||
>
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||
<span>{isAccepting ? "Accepting..." : "Accept"}</span>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
onClick={onDecline}
|
||||
disabled={issueStatus !== -2}
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4 text-red-500" />
|
||||
<span>Decline</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
)}
|
||||
{(isAllowed || user?.id === issue?.created_by) && (
|
||||
<div className="flex-shrink-0">
|
||||
<SecondaryButton size="sm" className="flex gap-2 items-center" onClick={onDelete}>
|
||||
<TrashIcon className="h-4 w-4 text-red-500" />
|
||||
<span>Delete</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
117
apps/app/components/inbox/inbox-issue-card.tsx
Normal file
117
apps/app/components/inbox/inbox-issue-card.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
|
||||
import { CalendarDaysIcon, ClockIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import type { IInboxIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
issue: IInboxIssue;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export const InboxIssueCard: React.FC<Props> = (props) => {
|
||||
const { issue, active } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, inboxId } = router.query;
|
||||
|
||||
const issueStatus = issue.issue_inbox[0].status;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${issue.bridge_id}`}
|
||||
>
|
||||
<a>
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
issueStatus === -2
|
||||
? "Pending issue"
|
||||
: issueStatus === -1
|
||||
? "Declined issue"
|
||||
: issueStatus === 0
|
||||
? "Snoozed issue"
|
||||
: issueStatus === 1
|
||||
? "Accepted issue"
|
||||
: "Marked as duplicate"
|
||||
}
|
||||
position="right"
|
||||
>
|
||||
<div
|
||||
id={issue.id}
|
||||
className={`relative min-h-[5rem] cursor-pointer select-none space-y-3 py-2 px-4 border-b border-brand-base hover:bg-brand-accent hover:bg-opacity-10 ${
|
||||
active ? "bg-brand-accent bg-opacity-5" : " "
|
||||
} ${issue.issue_inbox[0].status !== -2 ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="flex-shrink-0 text-brand-secondary text-xs">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</p>
|
||||
<h5 className="truncate text-sm">{issue.name}</h5>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Tooltip
|
||||
tooltipHeading="State"
|
||||
tooltipContent={addSpaceIfCamelCase(issue.state_detail?.name ?? "Triage")}
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded border border-brand-base shadow-sm text-xs px-2 py-[0.19rem] text-brand-secondary">
|
||||
{getStateGroupIcon(
|
||||
issue.state_detail?.group ?? "backlog",
|
||||
"14",
|
||||
"14",
|
||||
issue.state_detail?.color
|
||||
)}
|
||||
{issue.state_detail?.name ?? "Triage"}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}>
|
||||
<div
|
||||
className={`grid h-6 w-6 place-items-center rounded border items-center shadow-sm ${
|
||||
issue.priority === "urgent"
|
||||
? "border-red-500/20 bg-red-500/20 text-red-500"
|
||||
: issue.priority === "high"
|
||||
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
|
||||
: issue.priority === "medium"
|
||||
? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500"
|
||||
: issue.priority === "low"
|
||||
? "border-green-500/20 bg-green-500/20 text-green-500"
|
||||
: "border-brand-base"
|
||||
}`}
|
||||
>
|
||||
{getPriorityIcon(
|
||||
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
|
||||
"text-sm"
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
tooltipHeading="Created at"
|
||||
tooltipContent={`${renderShortNumericDateFormat(issue.created_at ?? "")}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 rounded border border-brand-base shadow-sm text-xs px-2 py-[0.19rem] text-brand-secondary">
|
||||
<CalendarDaysIcon className="h-3.5 w-3.5" />
|
||||
<span>{renderShortNumericDateFormat(issue.created_at ?? "")}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{issue.issue_inbox[0].snoozed_till && (
|
||||
<div className="text-xs flex items-center gap-1 text-brand-accent">
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
Snoozed till {renderShortNumericDateFormat(issue.issue_inbox[0].snoozed_till)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
259
apps/app/components/inbox/inbox-main-content.tsx
Normal file
259
apps/app/components/inbox/inbox-main-content.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react hook form
|
||||
import { useForm } from "react-hook-form";
|
||||
// contexts
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
// services
|
||||
import inboxServices from "services/inbox.service";
|
||||
// hooks
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// components
|
||||
import {
|
||||
AddComment,
|
||||
IssueActivitySection,
|
||||
IssueDescriptionForm,
|
||||
IssueDetailsSidebar,
|
||||
} from "components/issues";
|
||||
// ui
|
||||
import { Loader } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
DocumentDuplicateIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import type { IInboxIssue, IIssue } from "types";
|
||||
// fetch-keys
|
||||
import { INBOX_ISSUES, INBOX_ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
const defaultValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
description_html: "",
|
||||
estimate_point: null,
|
||||
assignees_list: [],
|
||||
priority: "low",
|
||||
target_date: new Date().toString(),
|
||||
labels_list: [],
|
||||
};
|
||||
|
||||
export const InboxMainContent: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query;
|
||||
|
||||
const { user } = useUserAuth();
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
const { params } = useInboxView();
|
||||
|
||||
const { reset, control, watch } = useForm<IIssue>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { data: issueDetails, mutate: mutateIssueDetails } = useSWR(
|
||||
workspaceSlug && projectId && inboxId && inboxIssueId
|
||||
? INBOX_ISSUE_DETAILS(inboxId.toString(), inboxIssueId.toString())
|
||||
: null,
|
||||
workspaceSlug && projectId && inboxId && inboxIssueId
|
||||
? () =>
|
||||
inboxServices.getInboxIssueById(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString(),
|
||||
inboxIssueId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issueDetails || !inboxIssueId) return;
|
||||
|
||||
reset({
|
||||
...issueDetails,
|
||||
assignees_list:
|
||||
issueDetails.assignees_list ?? (issueDetails.assignee_details ?? []).map((user) => user.id),
|
||||
labels_list: issueDetails.labels_list ?? issueDetails.labels,
|
||||
});
|
||||
}, [issueDetails, reset, inboxIssueId]);
|
||||
|
||||
const submitChanges = useCallback(
|
||||
async (formData: Partial<IInboxIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !inboxIssueId || !inboxId || !issueDetails) return;
|
||||
|
||||
mutateIssueDetails((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
...formData,
|
||||
};
|
||||
}, false);
|
||||
mutate<IInboxIssue[]>(
|
||||
INBOX_ISSUES(inboxId.toString(), params),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((i) => {
|
||||
if (i.bridge_id === inboxIssueId) {
|
||||
return {
|
||||
...i,
|
||||
...formData,
|
||||
};
|
||||
}
|
||||
|
||||
return i;
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
const payload = { issue: { ...formData } };
|
||||
|
||||
await inboxServices
|
||||
.patchInboxIssue(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString(),
|
||||
issueDetails.issue_inbox[0].id,
|
||||
payload,
|
||||
user
|
||||
)
|
||||
.then(() => {
|
||||
mutateIssueDetails();
|
||||
mutate(INBOX_ISSUES(inboxId.toString(), params));
|
||||
});
|
||||
},
|
||||
[
|
||||
workspaceSlug,
|
||||
inboxIssueId,
|
||||
projectId,
|
||||
mutateIssueDetails,
|
||||
inboxId,
|
||||
user,
|
||||
issueDetails,
|
||||
params,
|
||||
]
|
||||
);
|
||||
|
||||
const issueStatus = issueDetails?.issue_inbox[0].status;
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueDetails ? (
|
||||
<div className="flex h-full overflow-auto divide-x">
|
||||
<div className="basis-2/3 h-full overflow-auto p-5 space-y-3">
|
||||
<div
|
||||
className={`flex items-center gap-2 p-3 text-sm border rounded-md ${
|
||||
issueStatus === -2
|
||||
? "text-orange-500 border-orange-500 bg-orange-500/10"
|
||||
: issueStatus === -1
|
||||
? "text-red-500 border-red-500 bg-red-500/10"
|
||||
: issueStatus === 0
|
||||
? "text-blue-500 border-blue-500 bg-blue-500/10"
|
||||
: issueStatus === 1
|
||||
? "text-green-500 border-green-500 bg-green-500/10"
|
||||
: issueStatus === 2
|
||||
? "text-yellow-500 border-yellow-500 bg-yellow-500/10"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{issueStatus === -2 ? (
|
||||
<>
|
||||
<ExclamationTriangleIcon className="h-5 w-5" />
|
||||
<p>This issue is still pending.</p>
|
||||
</>
|
||||
) : issueStatus === -1 ? (
|
||||
<>
|
||||
<XCircleIcon className="h-5 w-5" />
|
||||
<p>This issue has been declined.</p>
|
||||
</>
|
||||
) : issueStatus === 0 ? (
|
||||
<>
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
<p>
|
||||
This issue has been snoozed till{" "}
|
||||
{renderShortNumericDateFormat(issueDetails.issue_inbox[0].snoozed_till ?? "")}.
|
||||
</p>
|
||||
</>
|
||||
) : issueStatus === 1 ? (
|
||||
<>
|
||||
<CheckCircleIcon className="h-5 w-5" />
|
||||
<p>This issue has been accepted.</p>
|
||||
</>
|
||||
) : issueStatus === 2 ? (
|
||||
<>
|
||||
<DocumentDuplicateIcon className="h-5 w-5" />
|
||||
<p className="flex items-center gap-1">
|
||||
This issue has been marked as a duplicate of
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueDetails.issue_inbox[0].duplicate_to}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline flex items-center gap-2"
|
||||
>
|
||||
this issue <ArrowTopRightOnSquareIcon className="h-3 w-3" />
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<IssueDescriptionForm
|
||||
issue={{
|
||||
name: issueDetails.name,
|
||||
description: issueDetails.description,
|
||||
description_html: issueDetails.description_html,
|
||||
}}
|
||||
handleFormSubmit={submitChanges}
|
||||
isAllowed={
|
||||
memberRole.isMember || memberRole.isOwner || user?.id === issueDetails.created_by
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg text-brand-base">Comments/Activity</h3>
|
||||
<IssueActivitySection issueId={issueDetails.id} user={user} />
|
||||
<AddComment issueId={issueDetails.id} user={user} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="basis-1/3 space-y-5 border-brand-base p-5">
|
||||
<IssueDetailsSidebar
|
||||
control={control}
|
||||
issueDetail={issueDetails}
|
||||
submitChanges={submitChanges}
|
||||
watch={watch}
|
||||
fieldsToShow={["assignee", "priority", "estimate", "dueDate", "label", "state"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="flex h-full gap-5 p-5">
|
||||
<div className="basis-2/3 space-y-2">
|
||||
<Loader.Item height="30px" width="40%" />
|
||||
<Loader.Item height="15px" width="60%" />
|
||||
<Loader.Item height="15px" width="60%" />
|
||||
<Loader.Item height="15px" width="40%" />
|
||||
</div>
|
||||
<div className="basis-1/3 space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</div>
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
8
apps/app/components/inbox/index.ts
Normal file
8
apps/app/components/inbox/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export * from "./decline-issue-modal";
|
||||
export * from "./delete-issue-modal";
|
||||
export * from "./filters-dropdown";
|
||||
export * from "./inbox-action-headers";
|
||||
export * from "./inbox-issue-card";
|
||||
export * from "./inbox-main-content";
|
||||
export * from "./issues-list-sidebar";
|
||||
export * from "./select-duplicate";
|
44
apps/app/components/inbox/issues-list-sidebar.tsx
Normal file
44
apps/app/components/inbox/issues-list-sidebar.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
// components
|
||||
import { InboxIssueCard } from "components/inbox";
|
||||
// ui
|
||||
import { Loader } from "components/ui";
|
||||
|
||||
export const IssuesListSidebar = () => {
|
||||
const router = useRouter();
|
||||
const { inboxIssueId } = router.query;
|
||||
|
||||
const { issues: inboxIssues } = useInboxView();
|
||||
|
||||
return (
|
||||
<>
|
||||
{inboxIssues ? (
|
||||
inboxIssues.length > 0 ? (
|
||||
<div className="divide-y divide-brand-base overflow-auto h-full pb-10">
|
||||
{inboxIssues.map((issue) => (
|
||||
<InboxIssueCard
|
||||
key={issue.id}
|
||||
active={issue.bridge_id === inboxIssueId}
|
||||
issue={issue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full p-4 grid place-items-center text-center text-sm text-brand-secondary">
|
||||
No issues found for the selected filters. Try changing the filters.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="p-4 space-y-4">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
193
apps/app/components/inbox/select-duplicate.tsx
Normal file
193
apps/app/components/inbox/select-duplicate.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { LayerDiagonalIcon } from "components/icons";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
value?: string | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (issueId: string) => void;
|
||||
};
|
||||
|
||||
export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
|
||||
const { isOpen, onClose, onSubmit, value } = props;
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedItem, setSelectedItem] = useState<string>("");
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
: null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
issuesServices
|
||||
.getIssues(workspaceSlug as string, projectId as string)
|
||||
.then((res) => res.filter((issue) => issue.id !== issueId))
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedItem("");
|
||||
return;
|
||||
} else setSelectedItem(value);
|
||||
}, [value]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedItem || selectedItem.length === 0)
|
||||
return setToastAlert({
|
||||
title: "Error",
|
||||
type: "error",
|
||||
});
|
||||
onSubmit(selectedItem);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const filteredIssues =
|
||||
(query === "" ? issues : issues?.filter((issue) => issue.name.includes(query))) ?? [];
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
<div className="flex flex-wrap items-start py-2">
|
||||
<div className="space-y-1 sm:basis-1/2">
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform rounded-xl border border-brand-base bg-brand-base shadow-2xl transition-all">
|
||||
<Combobox
|
||||
value={selectedItem}
|
||||
onChange={(value) => {
|
||||
setSelectedItem(value);
|
||||
}}
|
||||
>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-base text-opacity-40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Combobox.Options
|
||||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-brand-base overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-brand-base">
|
||||
Select issue
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-brand-base">
|
||||
{filteredIssues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="div"
|
||||
value={issue.id}
|
||||
className={({ active, selected }) =>
|
||||
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${
|
||||
active || selected ? "bg-brand-surface-2 text-brand-base" : ""
|
||||
} `
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-brand-secondary">
|
||||
{
|
||||
issues?.find((i) => i.id === issue.id)?.project_detail
|
||||
?.identifier
|
||||
}
|
||||
-{issue.sequence_id}
|
||||
</span>
|
||||
<span className="text-brand-muted-1">{issue.name}</span>
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-sm text-brand-secondary">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-brand-surface-2 px-2 py-1">C</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
|
||||
{filteredIssues.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton onClick={handleSubmit}>Mark as original</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
@ -4,6 +4,14 @@ import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useEstimateOption from "hooks/use-estimate-option";
|
||||
// components
|
||||
import { CommentCard } from "components/issues/comment";
|
||||
// ui
|
||||
import { Loader } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
@ -17,20 +25,13 @@ import {
|
||||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// components
|
||||
import { CommentCard } from "components/issues/comment";
|
||||
// ui
|
||||
import { Loader } from "components/ui";
|
||||
|
||||
// helpers
|
||||
import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper";
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssueComment, IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_ACTIVITY, PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
import useEstimateOption from "hooks/use-estimate-option";
|
||||
|
||||
const activityDetails: {
|
||||
[key: string]: {
|
||||
@ -60,7 +61,7 @@ const activityDetails: {
|
||||
},
|
||||
estimate_point: {
|
||||
message: "set the estimate point to",
|
||||
icon: <PlayIcon className="h-3 w-3 -rotate-90 text-gray-500" aria-hidden="true" />,
|
||||
icon: <PlayIcon className="h-3 w-3 -rotate-90 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
labels: {
|
||||
icon: <TagIcon height="12" width="12" color="#6b7280" />,
|
||||
@ -99,25 +100,26 @@ const activityDetails: {
|
||||
},
|
||||
estimate: {
|
||||
message: "updated the estimate",
|
||||
icon: <PlayIcon className="h-3 w-3 -rotate-90 text-gray-500" aria-hidden="true" />,
|
||||
icon: <PlayIcon className="h-3 w-3 -rotate-90 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
link: {
|
||||
message: "updated the link",
|
||||
icon: <LinkIcon className="h-3 w-3 text-gray-500" aria-hidden="true" />,
|
||||
icon: <LinkIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
attachment: {
|
||||
message: "updated the attachment",
|
||||
icon: <PaperClipIcon className="h-3 w-3 text-gray-500 " aria-hidden="true" />,
|
||||
icon: <PaperClipIcon className="h-3 w-3 text-brand-secondary" aria-hidden="true" />,
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
issueId: string;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
export const IssueActivitySection: React.FC<Props> = ({ user }) => {
|
||||
export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { isEstimateActive, estimatePoints } = useEstimateOption();
|
||||
|
||||
|
@ -41,10 +41,11 @@ const defaultValues: Partial<IIssueComment> = {
|
||||
};
|
||||
|
||||
type Props = {
|
||||
issueId: string;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
export const AddComment: React.FC<Props> = ({ user }) => {
|
||||
export const AddComment: React.FC<Props> = ({ issueId, user }) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
@ -56,7 +57,7 @@ export const AddComment: React.FC<Props> = ({ user }) => {
|
||||
const editorRef = React.useRef<any>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
|
@ -17,7 +17,7 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { SecondaryButton, DangerButton } from "components/ui";
|
||||
// types
|
||||
import type { ICurrentUserResponse, IIssue } from "types";
|
||||
import type { IIssue, ICurrentUserResponse } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
|
@ -4,8 +4,6 @@ import dynamic from "next/dynamic";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// contexts
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
// hooks
|
||||
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||
// components
|
||||
@ -28,16 +26,23 @@ export interface IssueDescriptionFormValues {
|
||||
}
|
||||
|
||||
export interface IssueDetailsProps {
|
||||
issue: IIssue;
|
||||
issue: {
|
||||
name: string;
|
||||
description: string;
|
||||
description_html: string;
|
||||
};
|
||||
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
|
||||
isAllowed: boolean;
|
||||
}
|
||||
|
||||
export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormSubmit }) => {
|
||||
export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
||||
issue,
|
||||
handleFormSubmit,
|
||||
isAllowed,
|
||||
}) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [characterLimit, setCharacterLimit] = useState(false);
|
||||
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
|
||||
const {
|
||||
@ -78,8 +83,6 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
|
||||
});
|
||||
}, [issue, reset]);
|
||||
|
||||
const isNotAllowed = memberRole.isGuest || memberRole.isViewer;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
@ -106,6 +109,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
|
||||
overflow-hidden rounded border-none bg-transparent
|
||||
px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-theme"
|
||||
role="textbox"
|
||||
disabled={!isAllowed}
|
||||
/>
|
||||
{characterLimit && (
|
||||
<div className="pointer-events-none absolute bottom-0 right-0 z-[2] rounded bg-brand-surface-2 p-1 text-xs">
|
||||
@ -156,7 +160,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormS
|
||||
});
|
||||
}}
|
||||
placeholder="Description"
|
||||
editable={!isNotAllowed}
|
||||
editable={isAllowed}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
@ -92,6 +92,19 @@ export interface IssueFormProps {
|
||||
handleClose: () => void;
|
||||
status: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
fieldsToShow: (
|
||||
| "project"
|
||||
| "name"
|
||||
| "description"
|
||||
| "state"
|
||||
| "priority"
|
||||
| "assignee"
|
||||
| "label"
|
||||
| "dueDate"
|
||||
| "estimate"
|
||||
| "parent"
|
||||
| "all"
|
||||
)[];
|
||||
}
|
||||
|
||||
export const IssueForm: FC<IssueFormProps> = ({
|
||||
@ -105,6 +118,7 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
handleClose,
|
||||
status,
|
||||
user,
|
||||
fieldsToShow,
|
||||
}) => {
|
||||
// states
|
||||
const [mostSimilarIssue, setMostSimilarIssue] = useState<IIssue | undefined>();
|
||||
@ -252,243 +266,271 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
<form onSubmit={handleSubmit(handleCreateUpdateIssue)}>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="project"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueProjectSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
setActiveProject={setActiveProject}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="project"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueProjectSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
setActiveProject={setActiveProject}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<h3 className="text-xl font-semibold leading-6 text-brand-base">
|
||||
{status ? "Update" : "Create"} Issue
|
||||
</h3>
|
||||
</div>
|
||||
{watch("parent") && watch("parent") !== "" ? (
|
||||
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-brand-surface-2 p-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issues.find((i) => i.id === watch("parent"))?.state_detail
|
||||
.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-brand-secondary">
|
||||
{/* {projects?.find((p) => p.id === projectId)?.identifier}- */}
|
||||
{issues.find((i) => i.id === watch("parent"))?.sequence_id}
|
||||
</span>
|
||||
<span className="truncate font-medium">
|
||||
{issues.find((i) => i.id === watch("parent"))?.name.substring(0, 50)}
|
||||
</span>
|
||||
<XMarkIcon
|
||||
className="h-3 w-3 cursor-pointer"
|
||||
onClick={() => setValue("parent", null)}
|
||||
/>
|
||||
{watch("parent") &&
|
||||
watch("parent") !== "" &&
|
||||
(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-brand-surface-2 p-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issues.find((i) => i.id === watch("parent"))?.state_detail
|
||||
.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-brand-secondary">
|
||||
{/* {projects?.find((p) => p.id === projectId)?.identifier}- */}
|
||||
{issues.find((i) => i.id === watch("parent"))?.sequence_id}
|
||||
</span>
|
||||
<span className="truncate font-medium">
|
||||
{issues.find((i) => i.id === watch("parent"))?.name.substring(0, 50)}
|
||||
</span>
|
||||
<XMarkIcon
|
||||
className="h-3 w-3 cursor-pointer"
|
||||
onClick={() => setValue("parent", null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
onChange={handleTitleChange}
|
||||
className="resize-none text-xl"
|
||||
placeholder="Title"
|
||||
autoComplete="off"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Title is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Title should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{mostSimilarIssue && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-sm text-brand-secondary">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${mostSimilarIssue.id}`}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("name")) && (
|
||||
<div>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
onChange={handleTitleChange}
|
||||
className="resize-none text-xl"
|
||||
placeholder="Title"
|
||||
autoComplete="off"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Title is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Title should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{mostSimilarIssue && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-sm text-brand-secondary">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${mostSimilarIssue.id}`}
|
||||
>
|
||||
<a target="_blank" type="button" className="inline text-left">
|
||||
<span>Did you mean </span>
|
||||
<span className="italic">
|
||||
{mostSimilarIssue.project_detail.identifier}-
|
||||
{mostSimilarIssue.sequence_id}: {mostSimilarIssue.name}{" "}
|
||||
</span>
|
||||
?
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-brand-accent"
|
||||
onClick={() => {
|
||||
setMostSimilarIssue(undefined);
|
||||
}}
|
||||
>
|
||||
<a target="_blank" type="button" className="inline text-left">
|
||||
<span>Did you mean </span>
|
||||
<span className="italic">
|
||||
{mostSimilarIssue.project_detail.identifier}-
|
||||
{mostSimilarIssue.sequence_id}: {mostSimilarIssue.name}{" "}
|
||||
</span>
|
||||
?
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && (
|
||||
<div className="relative">
|
||||
<div className="-mb-2 flex justify-end">
|
||||
{issueName && issueName !== "" && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-brand-surface-1 ${
|
||||
iAmFeelingLucky ? "cursor-wait" : ""
|
||||
}`}
|
||||
onClick={handleAutoGenerateDescription}
|
||||
disabled={iAmFeelingLucky}
|
||||
>
|
||||
{iAmFeelingLucky ? (
|
||||
"Generating response..."
|
||||
) : (
|
||||
<>
|
||||
<SparklesIcon className="h-4 w-4" />I{"'"}m feeling lucky
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-brand-accent"
|
||||
onClick={() => {
|
||||
setMostSimilarIssue(undefined);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-brand-surface-1"
|
||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||
>
|
||||
No
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="-mb-2 flex justify-end">
|
||||
{issueName && issueName !== "" && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-brand-surface-1 ${
|
||||
iAmFeelingLucky ? "cursor-wait" : ""
|
||||
}`}
|
||||
onClick={handleAutoGenerateDescription}
|
||||
disabled={iAmFeelingLucky}
|
||||
>
|
||||
{iAmFeelingLucky ? (
|
||||
"Generating response..."
|
||||
) : (
|
||||
<>
|
||||
<SparklesIcon className="h-4 w-4" />I{"'"}m feeling lucky
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-brand-surface-1"
|
||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={
|
||||
!value || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Description"
|
||||
ref={editorRef}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<GptAssistantModal
|
||||
isOpen={gptAssistantModal}
|
||||
handleClose={() => {
|
||||
setGptAssistantModal(false);
|
||||
// this is done so that the title do not reset after gpt popover closed
|
||||
reset(getValues());
|
||||
}}
|
||||
inset="top-2 left-0"
|
||||
content=""
|
||||
htmlContent={watch("description_html")}
|
||||
onResponse={(response) => {
|
||||
handleAiAssistance(response);
|
||||
}}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={
|
||||
!value || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||
placeholder="Description"
|
||||
ref={editorRef}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<GptAssistantModal
|
||||
isOpen={gptAssistantModal}
|
||||
handleClose={() => {
|
||||
setGptAssistantModal(false);
|
||||
// this is done so that the title do not reset after gpt popover closed
|
||||
reset(getValues());
|
||||
}}
|
||||
inset="top-2 left-0"
|
||||
content=""
|
||||
htmlContent={watch("description_html")}
|
||||
onResponse={(response) => {
|
||||
handleAiAssistance(response);
|
||||
}}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueStateSelect
|
||||
setIsOpen={setStateModal}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssuePrioritySelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueAssigneeSelect projectId={projectId} value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="labels"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueLabelSelect
|
||||
setIsOpen={setLabelModal}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
name="state"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueDateSelect value={value} onChange={onChange} />
|
||||
<IssueStateSelect
|
||||
setIsOpen={setStateModal}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
name="priority"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueEstimateSelect value={value} onChange={onChange} />
|
||||
<IssuePrioritySelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<IssueParentSelect
|
||||
control={control}
|
||||
isOpen={parentIssueListModalOpen}
|
||||
setIsOpen={setParentIssueListModalOpen}
|
||||
issues={issues ?? []}
|
||||
/>
|
||||
<CustomMenu ellipsis>
|
||||
{watch("parent") && watch("parent") !== "" ? (
|
||||
<>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueAssigneeSelect
|
||||
projectId={projectId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="labels"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueLabelSelect
|
||||
setIsOpen={setLabelModal}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueDateSelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && (
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueEstimateSelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
<IssueParentSelect
|
||||
control={control}
|
||||
isOpen={parentIssueListModalOpen}
|
||||
setIsOpen={setParentIssueListModalOpen}
|
||||
issues={issues ?? []}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
<CustomMenu ellipsis>
|
||||
{watch("parent") && watch("parent") !== "" ? (
|
||||
<>
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
Change parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => setValue("parent", null)}
|
||||
>
|
||||
Remove parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
Change parent issue
|
||||
Select Parent Issue
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => setValue("parent", null)}
|
||||
>
|
||||
Remove parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
Select Parent Issue
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,11 +10,13 @@ import { Dialog, Transition } from "@headlessui/react";
|
||||
import projectService from "services/project.service";
|
||||
import modulesService from "services/modules.service";
|
||||
import issuesService from "services/issues.service";
|
||||
import inboxServices from "services/inbox.service";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
// components
|
||||
import { IssueForm } from "components/issues";
|
||||
// types
|
||||
@ -32,7 +34,10 @@ import {
|
||||
CYCLE_DETAILS,
|
||||
MODULE_DETAILS,
|
||||
VIEW_ISSUES,
|
||||
INBOX_ISSUES,
|
||||
} from "constants/fetch-keys";
|
||||
// constants
|
||||
import { INBOX_ISSUE_SOURCE } from "constants/inbox";
|
||||
|
||||
export interface IssuesModalProps {
|
||||
isOpen: boolean;
|
||||
@ -40,6 +45,19 @@ export interface IssuesModalProps {
|
||||
data?: IIssue | null;
|
||||
prePopulateData?: Partial<IIssue>;
|
||||
isUpdatingSingleIssue?: boolean;
|
||||
fieldsToShow?: (
|
||||
| "project"
|
||||
| "name"
|
||||
| "description"
|
||||
| "state"
|
||||
| "priority"
|
||||
| "assignee"
|
||||
| "label"
|
||||
| "dueDate"
|
||||
| "estimate"
|
||||
| "parent"
|
||||
| "all"
|
||||
)[];
|
||||
}
|
||||
|
||||
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
@ -48,17 +66,19 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
data,
|
||||
prePopulateData,
|
||||
isUpdatingSingleIssue = false,
|
||||
fieldsToShow = ["all"],
|
||||
}) => {
|
||||
// states
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
const [activeProject, setActiveProject] = useState<string | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId, inboxId } = router.query;
|
||||
|
||||
const { issueView, params } = useIssuesView();
|
||||
const { params: calendarParams } = useCalendarIssuesView();
|
||||
const { order_by, group_by, ...viewGanttParams } = params;
|
||||
const { params: inboxParams } = useInboxView();
|
||||
|
||||
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
|
||||
if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string };
|
||||
@ -140,6 +160,45 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const addIssueToInbox = async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !inboxId) return;
|
||||
|
||||
const payload = {
|
||||
issue: {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
description_html: formData.description_html,
|
||||
priority: formData.priority,
|
||||
},
|
||||
source: INBOX_ISSUE_SOURCE,
|
||||
};
|
||||
|
||||
await inboxServices
|
||||
.createInboxIssue(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString(),
|
||||
payload,
|
||||
user
|
||||
)
|
||||
.then((res) => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue created successfully.",
|
||||
});
|
||||
|
||||
mutate(INBOX_ISSUES(inboxId.toString(), inboxParams));
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const calendarFetchKey = cycleId
|
||||
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams)
|
||||
: moduleId
|
||||
@ -157,37 +216,40 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "");
|
||||
|
||||
const createIssue = async (payload: Partial<IIssue>) => {
|
||||
if (!workspaceSlug) return;
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
await issuesService
|
||||
.createIssues(workspaceSlug as string, activeProject ?? "", payload, user)
|
||||
.then(async (res) => {
|
||||
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
|
||||
if (payload.cycle && payload.cycle !== "") await addIssueToCycle(res.id, payload.cycle);
|
||||
if (payload.module && payload.module !== "") await addIssueToModule(res.id, payload.module);
|
||||
if (inboxId) await addIssueToInbox(payload);
|
||||
else
|
||||
await issuesService
|
||||
.createIssues(workspaceSlug as string, activeProject ?? "", payload, user)
|
||||
.then(async (res) => {
|
||||
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
|
||||
if (payload.cycle && payload.cycle !== "") await addIssueToCycle(res.id, payload.cycle);
|
||||
if (payload.module && payload.module !== "")
|
||||
await addIssueToModule(res.id, payload.module);
|
||||
|
||||
if (issueView === "calendar") mutate(calendarFetchKey);
|
||||
if (issueView === "gantt_chart") mutate(ganttFetchKey);
|
||||
if (issueView === "calendar") mutate(calendarFetchKey);
|
||||
if (issueView === "gantt_chart") mutate(ganttFetchKey);
|
||||
|
||||
if (!createMore) handleClose();
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue created successfully.",
|
||||
});
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue created successfully.",
|
||||
if (payload.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE);
|
||||
|
||||
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
if (payload.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE);
|
||||
|
||||
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
if (!createMore) handleClose();
|
||||
};
|
||||
|
||||
const updateIssue = async (payload: Partial<IIssue>) => {
|
||||
@ -226,8 +288,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
|
||||
const payload: Partial<IIssue> = {
|
||||
...formData,
|
||||
assignees_list: formData.assignees,
|
||||
labels_list: formData.labels,
|
||||
assignees_list: formData.assignees ?? [],
|
||||
labels_list: formData.labels ?? [],
|
||||
description: formData.description ?? "",
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
};
|
||||
@ -274,6 +336,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
setActiveProject={setActiveProject}
|
||||
status={data ? true : false}
|
||||
user={user}
|
||||
fieldsToShow={fieldsToShow}
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
@ -67,7 +67,7 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
|
||||
console.log(error);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId]
|
||||
[workspaceSlug, projectId, user]
|
||||
);
|
||||
|
||||
const handleCopyText = () => {
|
||||
|
@ -53,10 +53,26 @@ import type { ICycle, IIssue, IIssueLabels, IIssueLink, IModule } from "types";
|
||||
import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
control: any;
|
||||
submitChanges: (formData: any) => void;
|
||||
issueDetail: IIssue | undefined;
|
||||
watch: UseFormWatch<IIssue>;
|
||||
fieldsToShow?: (
|
||||
| "state"
|
||||
| "assignee"
|
||||
| "priority"
|
||||
| "estimate"
|
||||
| "parent"
|
||||
| "blocker"
|
||||
| "blocked"
|
||||
| "dueDate"
|
||||
| "cycle"
|
||||
| "module"
|
||||
| "label"
|
||||
| "link"
|
||||
| "delete"
|
||||
| "all"
|
||||
)[];
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IIssueLabels> = {
|
||||
@ -69,6 +85,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
submitChanges,
|
||||
issueDetail,
|
||||
watch: watchIssue,
|
||||
fieldsToShow = ["all"],
|
||||
}) => {
|
||||
const [createLabelForm, setCreateLabelForm] = useState(false);
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
@ -140,7 +157,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
mutate(ISSUE_DETAILS(issueId as string));
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, issueId, issueDetail]
|
||||
[workspaceSlug, projectId, issueId, issueDetail, user]
|
||||
);
|
||||
|
||||
const handleModuleChange = useCallback(
|
||||
@ -161,7 +178,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
mutate(ISSUE_DETAILS(issueId as string));
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, issueId, issueDetail]
|
||||
[workspaceSlug, projectId, issueId, issueDetail, user]
|
||||
);
|
||||
|
||||
const handleCreateLink = async (formData: IIssueLink) => {
|
||||
@ -230,6 +247,25 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
reset();
|
||||
}, [createLabelForm, reset]);
|
||||
|
||||
const showFirstSection =
|
||||
fieldsToShow.includes("all") ||
|
||||
fieldsToShow.includes("state") ||
|
||||
fieldsToShow.includes("assignee") ||
|
||||
fieldsToShow.includes("priority") ||
|
||||
fieldsToShow.includes("estimate");
|
||||
|
||||
const showSecondSection =
|
||||
fieldsToShow.includes("all") ||
|
||||
fieldsToShow.includes("parent") ||
|
||||
fieldsToShow.includes("blocker") ||
|
||||
fieldsToShow.includes("blocked") ||
|
||||
fieldsToShow.includes("dueDate");
|
||||
|
||||
const showThirdSection =
|
||||
fieldsToShow.includes("all") ||
|
||||
fieldsToShow.includes("cycle") ||
|
||||
fieldsToShow.includes("module");
|
||||
|
||||
const isNotAllowed = memberRole.isGuest || memberRole.isViewer;
|
||||
|
||||
return (
|
||||
@ -258,7 +294,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
>
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{!isNotAllowed && (
|
||||
{!isNotAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-500/20 focus:outline-none"
|
||||
@ -270,402 +306,434 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y-2 divide-brand-base">
|
||||
<div className="py-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarStateSelect
|
||||
value={value}
|
||||
onChange={(val: string) => submitChanges({ state: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees_list"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarAssigneeSelect
|
||||
value={value}
|
||||
onChange={(val: string[]) => submitChanges({ assignees_list: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarPrioritySelect
|
||||
value={value}
|
||||
onChange={(val: string) => submitChanges({ priority: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarEstimateSelect
|
||||
value={value}
|
||||
onChange={(val: number | null) => submitChanges({ estimate_point: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<SidebarParentSelect
|
||||
control={control}
|
||||
submitChanges={submitChanges}
|
||||
issuesList={
|
||||
issues?.filter(
|
||||
(i) =>
|
||||
i.id !== issueDetail?.id &&
|
||||
i.id !== issueDetail?.parent &&
|
||||
i.parent !== issueDetail?.id
|
||||
) ?? []
|
||||
}
|
||||
customDisplay={
|
||||
issueDetail?.parent_detail ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded bg-brand-surface-2 px-3 py-2 text-xs"
|
||||
onClick={() => submitChanges({ parent: null })}
|
||||
>
|
||||
{issueDetail.parent_detail?.name}
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="inline-block rounded bg-brand-surface-1 px-3 py-2 text-xs">
|
||||
No parent selected
|
||||
</div>
|
||||
)
|
||||
}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
<SidebarBlockerSelect
|
||||
submitChanges={submitChanges}
|
||||
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
<SidebarBlockedSelect
|
||||
submitChanges={submitChanges}
|
||||
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm text-brand-secondary sm:basis-1/2">
|
||||
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Due date</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
{showFirstSection && (
|
||||
<div className="py-1">
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
name="state"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomDatePicker
|
||||
placeholder="Due date"
|
||||
<SidebarStateSelect
|
||||
value={value}
|
||||
onChange={(val) =>
|
||||
submitChanges({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
className="bg-brand-surface-1"
|
||||
disabled={isNotAllowed}
|
||||
onChange={(val: string) => submitChanges({ state: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<SidebarCycleSelect
|
||||
issueDetail={issueDetail}
|
||||
handleCycleChange={handleCycleChange}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
<SidebarModuleSelect
|
||||
issueDetail={issueDetail}
|
||||
handleModuleChange={handleModuleChange}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 py-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex basis-1/2 items-center gap-x-2 text-sm text-brand-secondary">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<p>Label</p>
|
||||
</div>
|
||||
<div className="basis-1/2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{watchIssue("labels_list")?.map((labelId) => {
|
||||
const label = issueLabels?.find((l) => l.id === labelId);
|
||||
|
||||
if (label)
|
||||
return (
|
||||
<span
|
||||
key={label.id}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-brand-base px-1 py-0.5 text-xs hover:border-red-500/20 hover:bg-red-500/20"
|
||||
onClick={() => {
|
||||
const updatedLabels = watchIssue("labels_list")?.filter(
|
||||
(l) => l !== labelId
|
||||
);
|
||||
submitChanges({
|
||||
labels_list: updatedLabels,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="labels_list"
|
||||
name="assignees_list"
|
||||
render={({ field: { value } }) => (
|
||||
<Listbox
|
||||
as="div"
|
||||
<SidebarAssigneeSelect
|
||||
value={value}
|
||||
onChange={(val: any) => submitChanges({ labels_list: val })}
|
||||
className="flex-shrink-0"
|
||||
multiple
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{({ open }) => (
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
className={`flex ${
|
||||
isNotAllowed
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-brand-surface-1"
|
||||
} items-center gap-2 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary`}
|
||||
>
|
||||
Select Label
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-28 w-40 overflow-auto rounded-md bg-brand-surface-2 py-1 text-xs shadow-lg border border-brand-base focus:outline-none">
|
||||
<div className="py-1">
|
||||
{issueLabels ? (
|
||||
issueLabels.length > 0 ? (
|
||||
issueLabels.map((label: IIssueLabels) => {
|
||||
const children = issueLabels?.filter(
|
||||
(l) => l.parent === label.id
|
||||
);
|
||||
|
||||
if (children.length === 0) {
|
||||
if (!label.parent)
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={label.id}
|
||||
className={({ active, selected }) =>
|
||||
`${
|
||||
active || selected ? "bg-brand-surface-1" : ""
|
||||
} ${
|
||||
selected ? "" : "text-brand-secondary"
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2`
|
||||
}
|
||||
value={label.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label.color && label.color !== ""
|
||||
? label.color
|
||||
: "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</Listbox.Option>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<div className="border-y border-brand-base bg-brand-surface-1">
|
||||
<div className="flex select-none items-center gap-2 truncate p-2 font-medium text-brand-base">
|
||||
<RectangleGroupIcon className="h-3 w-3" />
|
||||
{label.name}
|
||||
</div>
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<Listbox.Option
|
||||
key={child.id}
|
||||
className={({ active, selected }) =>
|
||||
`${active || selected ? "bg-brand-base" : ""} ${
|
||||
selected ? "" : "text-brand-secondary"
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2`
|
||||
}
|
||||
value={child.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child?.color ?? "black",
|
||||
}}
|
||||
/>
|
||||
{child.name}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center">No labels found</div>
|
||||
)
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
onChange={(val: string[]) => submitChanges({ assignees_list: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex ${
|
||||
isNotAllowed
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-brand-surface-1"
|
||||
} items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary`}
|
||||
onClick={() => setCreateLabelForm((prevData) => !prevData)}
|
||||
>
|
||||
{createLabelForm ? (
|
||||
<>
|
||||
<XMarkIcon className="h-3 w-3" /> Cancel
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-3 w-3" /> New
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{createLabelForm && (
|
||||
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(handleNewLabel)}>
|
||||
<div>
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`flex items-center gap-1 rounded-md bg-brand-surface-2 p-1 outline-none focus:ring-2 focus:ring-brand-accent`}
|
||||
>
|
||||
{watch("color") && watch("color") !== "" && (
|
||||
<span
|
||||
className="h-5 w-5 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("color") ?? "black",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 bottom-8 z-10 mt-1 max-w-xs transform px-2 sm:px-0">
|
||||
<Controller
|
||||
name="color"
|
||||
control={controlLabel}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TwitterPicker
|
||||
color={value}
|
||||
onChange={(value) => onChange(value.hex)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarPrioritySelect
|
||||
value={value}
|
||||
onChange={(val: string) => submitChanges({ priority: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Title"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "This is required",
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="grid place-items-center rounded bg-red-500 p-2.5"
|
||||
onClick={() => setCreateLabelForm(false)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="grid place-items-center rounded bg-green-500 p-2.5"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
</form>
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value } }) => (
|
||||
<SidebarEstimateSelect
|
||||
value={value}
|
||||
onChange={(val: number | null) => submitChanges({ estimate_point: val })}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showSecondSection && (
|
||||
<div className="py-1">
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
<SidebarParentSelect
|
||||
control={control}
|
||||
submitChanges={submitChanges}
|
||||
issuesList={
|
||||
issues?.filter(
|
||||
(i) =>
|
||||
i.id !== issueDetail?.id &&
|
||||
i.id !== issueDetail?.parent &&
|
||||
i.parent !== issueDetail?.id
|
||||
) ?? []
|
||||
}
|
||||
customDisplay={
|
||||
issueDetail?.parent_detail ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded bg-brand-surface-2 px-3 py-2 text-xs"
|
||||
onClick={() => submitChanges({ parent: null })}
|
||||
>
|
||||
{issueDetail.parent_detail?.name}
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="inline-block rounded bg-brand-surface-1 px-3 py-2 text-xs">
|
||||
No parent selected
|
||||
</div>
|
||||
)
|
||||
}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
|
||||
<SidebarBlockerSelect
|
||||
submitChanges={submitChanges}
|
||||
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
|
||||
<SidebarBlockedSelect
|
||||
submitChanges={submitChanges}
|
||||
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm text-brand-secondary sm:basis-1/2">
|
||||
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Due date</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomDatePicker
|
||||
placeholder="Due date"
|
||||
value={value}
|
||||
onChange={(val) =>
|
||||
submitChanges({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
className="bg-brand-surface-1"
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showThirdSection && (
|
||||
<div className="py-1">
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("cycle")) && (
|
||||
<SidebarCycleSelect
|
||||
issueDetail={issueDetail}
|
||||
handleCycleChange={handleCycleChange}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && (
|
||||
<SidebarModuleSelect
|
||||
issueDetail={issueDetail}
|
||||
handleModuleChange={handleModuleChange}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-[116px] py-1 text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4>Links</h4>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-1"
|
||||
onClick={() => setLinkModal(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && (
|
||||
<div className="space-y-3 py-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex basis-1/2 items-center gap-x-2 text-sm text-brand-secondary">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<p>Label</p>
|
||||
</div>
|
||||
<div className="basis-1/2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{watchIssue("labels_list")?.map((labelId) => {
|
||||
const label = issueLabels?.find((l) => l.id === labelId);
|
||||
|
||||
if (label)
|
||||
return (
|
||||
<span
|
||||
key={label.id}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-brand-base px-1 py-0.5 text-xs hover:border-red-500/20 hover:bg-red-500/20"
|
||||
onClick={() => {
|
||||
const updatedLabels = watchIssue("labels_list")?.filter(
|
||||
(l) => l !== labelId
|
||||
);
|
||||
submitChanges({
|
||||
labels_list: updatedLabels,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<Controller
|
||||
control={control}
|
||||
name="labels_list"
|
||||
render={({ field: { value } }) => (
|
||||
<Listbox
|
||||
as="div"
|
||||
value={value}
|
||||
onChange={(val: any) => submitChanges({ labels_list: val })}
|
||||
className="flex-shrink-0"
|
||||
multiple
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{({ open }) => (
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
className={`flex ${
|
||||
isNotAllowed
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-brand-surface-1"
|
||||
} items-center gap-2 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary`}
|
||||
>
|
||||
Select Label
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-28 w-40 overflow-auto rounded-md bg-brand-surface-2 py-1 text-xs shadow-lg border border-brand-base focus:outline-none">
|
||||
<div className="py-1">
|
||||
{issueLabels ? (
|
||||
issueLabels.length > 0 ? (
|
||||
issueLabels.map((label: IIssueLabels) => {
|
||||
const children = issueLabels?.filter(
|
||||
(l) => l.parent === label.id
|
||||
);
|
||||
|
||||
if (children.length === 0) {
|
||||
if (!label.parent)
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={label.id}
|
||||
className={({ active, selected }) =>
|
||||
`${
|
||||
active || selected ? "bg-brand-surface-1" : ""
|
||||
} ${
|
||||
selected ? "" : "text-brand-secondary"
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2`
|
||||
}
|
||||
value={label.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label.color && label.color !== ""
|
||||
? label.color
|
||||
: "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</Listbox.Option>
|
||||
);
|
||||
} else
|
||||
return (
|
||||
<div className="border-y border-brand-base bg-brand-surface-1">
|
||||
<div className="flex select-none items-center gap-2 truncate p-2 font-medium text-brand-base">
|
||||
<RectangleGroupIcon className="h-3 w-3" />
|
||||
{label.name}
|
||||
</div>
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<Listbox.Option
|
||||
key={child.id}
|
||||
className={({ active, selected }) =>
|
||||
`${
|
||||
active || selected ? "bg-brand-base" : ""
|
||||
} ${
|
||||
selected ? "" : "text-brand-secondary"
|
||||
} flex cursor-pointer select-none items-center gap-2 truncate p-2`
|
||||
}
|
||||
value={child.id}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: child?.color ?? "black",
|
||||
}}
|
||||
/>
|
||||
{child.name}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center">No labels found</div>
|
||||
)
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
/>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex ${
|
||||
isNotAllowed
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-brand-surface-1"
|
||||
} items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary`}
|
||||
onClick={() => setCreateLabelForm((prevData) => !prevData)}
|
||||
>
|
||||
{createLabelForm ? (
|
||||
<>
|
||||
<XMarkIcon className="h-3 w-3" /> Cancel
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-3 w-3" /> New
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{createLabelForm && (
|
||||
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(handleNewLabel)}>
|
||||
<div>
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`flex items-center gap-1 rounded-md bg-brand-surface-2 p-1 outline-none focus:ring-2 focus:ring-brand-accent`}
|
||||
>
|
||||
{watch("color") && watch("color") !== "" && (
|
||||
<span
|
||||
className="h-5 w-5 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("color") ?? "black",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 bottom-8 z-10 mt-1 max-w-xs transform px-2 sm:px-0">
|
||||
<Controller
|
||||
name="color"
|
||||
control={controlLabel}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TwitterPicker
|
||||
color={value}
|
||||
onChange={(value) => onChange(value.hex)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Title"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "This is required",
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="grid place-items-center rounded bg-red-500 p-2.5"
|
||||
onClick={() => setCreateLabelForm(false)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="grid place-items-center rounded bg-green-500 p-2.5"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{issueDetail?.issue_link && issueDetail.issue_link.length > 0 ? (
|
||||
<LinksList
|
||||
links={issueDetail.issue_link}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
|
||||
<div className="min-h-[116px] py-1 text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4>Links</h4>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-1"
|
||||
onClick={() => setLinkModal(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{issueDetail?.issue_link && issueDetail.issue_link.length > 0 ? (
|
||||
<LinksList
|
||||
links={issueDetail.issue_link}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -126,7 +126,7 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
})
|
||||
.finally(() => onClose());
|
||||
},
|
||||
[workspaceSlug, projectId, pageId, onClose, setToastAlert]
|
||||
[workspaceSlug, projectId, pageId, onClose, setToastAlert, user]
|
||||
);
|
||||
|
||||
const updatePageBlock = useCallback(
|
||||
@ -181,7 +181,7 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
})
|
||||
.finally(() => onClose());
|
||||
},
|
||||
[workspaceSlug, projectId, pageId, data, onClose, setIsSyncing]
|
||||
[workspaceSlug, projectId, pageId, data, onClose, setIsSyncing, user]
|
||||
);
|
||||
|
||||
const handleAutoGenerateDescription = async () => {
|
||||
|
@ -19,11 +19,7 @@ export const SecondaryButton: React.FC<ButtonProps> = ({
|
||||
: size === "md"
|
||||
? "rounded-md px-3.5 py-2 text-sm"
|
||||
: "rounded-lg px-4 py-2 text-base"
|
||||
} ${
|
||||
disabled
|
||||
? "cursor-not-allowed border-brand-base bg-brand-surface-1 hover:border-brand-base hover:border-opacity-100 hover:bg-brand-surface-1 hover:bg-opacity-100"
|
||||
: ""
|
||||
} ${
|
||||
} ${disabled ? "cursor-not-allowed border-brand-base bg-brand-surface-1" : ""} ${
|
||||
outline
|
||||
? "bg-transparent hover:bg-brand-surface-2"
|
||||
: "bg-brand-surface-2 hover:border-opacity-70 hover:bg-opacity-70"
|
||||
|
@ -20,7 +20,7 @@ import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fe
|
||||
import { PRIORITIES } from "constants/project";
|
||||
|
||||
type Props = {
|
||||
filters: IIssueFilterOptions | IQuery;
|
||||
filters: Partial<IIssueFilterOptions> | IQuery;
|
||||
onSelect: (option: any) => void;
|
||||
direction?: "left" | "right";
|
||||
height?: "sm" | "md" | "rg" | "lg";
|
||||
|
@ -23,6 +23,19 @@ const paramsToKey = (params: any) => {
|
||||
return `${stateKey}_${priorityKey}_${assigneesKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${targetDateKey}`;
|
||||
};
|
||||
|
||||
const inboxParamsToKey = (params: any) => {
|
||||
const { priority, inbox_status } = params;
|
||||
|
||||
let priorityKey = priority ? priority.split(",") : [];
|
||||
let inboxStatusKey = inbox_status ? inbox_status.split(",") : [];
|
||||
|
||||
// sorting each keys in ascending order
|
||||
priorityKey = priorityKey.sort().join("_");
|
||||
inboxStatusKey = inboxStatusKey.sort().join("_");
|
||||
|
||||
return `${priorityKey}_${inboxStatusKey}`;
|
||||
};
|
||||
|
||||
export const CURRENT_USER = "CURRENT_USER";
|
||||
export const USER_WORKSPACE_INVITATIONS = "USER_WORKSPACE_INVITATIONS";
|
||||
export const USER_WORKSPACES = "USER_WORKSPACES";
|
||||
@ -124,6 +137,19 @@ export const VIEW_ISSUES = (viewId: string, params: any) => {
|
||||
return `VIEW_ISSUES_${viewId.toUpperCase()}_${paramsKey.toUpperCase()}`;
|
||||
};
|
||||
|
||||
// inbox
|
||||
export const INBOX_LIST = (projectId: string) => `INBOX_LIST_${projectId.toUpperCase()}`;
|
||||
export const INBOX_DETAILS = (inboxId: string) => `INBOX_DETAILS_${inboxId.toUpperCase()}`;
|
||||
export const INBOX_ISSUES = (inboxId: string, params?: any) => {
|
||||
if (!params) return `INBOX_ISSUES_${inboxId.toUpperCase()}`;
|
||||
|
||||
const paramsKey = inboxParamsToKey(params);
|
||||
|
||||
return `INBOX_ISSUES_${inboxId.toUpperCase()}_${paramsKey.toUpperCase()}`;
|
||||
};
|
||||
export const INBOX_ISSUE_DETAILS = (inboxId: string, issueId: string) =>
|
||||
`INBOX_ISSUE_DETAILS_${inboxId.toUpperCase()}_${issueId.toUpperCase()}`;
|
||||
|
||||
// Issues
|
||||
export const ISSUE_DETAILS = (issueId: string) => `ISSUE_DETAILS_${issueId.toUpperCase()}`;
|
||||
export const SUB_ISSUES = (issueId: string) => `SUB_ISSUES_${issueId.toUpperCase()}`;
|
||||
|
9
apps/app/constants/inbox.ts
Normal file
9
apps/app/constants/inbox.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export const STATUS: { [key: string]: number } = {
|
||||
Pending: -2,
|
||||
Declined: -1,
|
||||
Snoozed: 0,
|
||||
Accepted: 1,
|
||||
Duplicate: 2,
|
||||
};
|
||||
|
||||
export const INBOX_ISSUE_SOURCE = "in-app";
|
163
apps/app/contexts/inbox-view-context.tsx
Normal file
163
apps/app/contexts/inbox-view-context.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import { createContext, useCallback, useEffect, useReducer } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// components
|
||||
import ToastAlert from "components/toast-alert";
|
||||
// services
|
||||
import inboxServices from "services/inbox.service";
|
||||
// types
|
||||
import { IInboxFilterOptions } from "types";
|
||||
// fetch-keys
|
||||
import { INBOX_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
export const inboxViewContext = createContext<ContextType>({} as ContextType);
|
||||
|
||||
type InboxViewProps = {
|
||||
filters: IInboxFilterOptions;
|
||||
};
|
||||
|
||||
type ReducerActionType = {
|
||||
type: "REHYDRATE_THEME" | "SET_FILTERS";
|
||||
payload?: Partial<InboxViewProps>;
|
||||
};
|
||||
|
||||
type ContextType = InboxViewProps & {
|
||||
setFilters: (filters: Partial<IInboxFilterOptions>) => void;
|
||||
};
|
||||
|
||||
type StateType = {
|
||||
filters: IInboxFilterOptions;
|
||||
};
|
||||
type ReducerFunctionType = (state: StateType, action: ReducerActionType) => StateType;
|
||||
|
||||
export const initialState: StateType = {
|
||||
filters: {
|
||||
priority: null,
|
||||
inbox_status: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const reducer: ReducerFunctionType = (state, action) => {
|
||||
const { type, payload } = action;
|
||||
|
||||
switch (type) {
|
||||
case "REHYDRATE_THEME": {
|
||||
return { ...initialState, ...payload };
|
||||
}
|
||||
|
||||
case "SET_FILTERS": {
|
||||
const newState = {
|
||||
...state,
|
||||
filters: {
|
||||
...state.filters,
|
||||
...payload,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
...newState,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const saveDataToServer = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
inboxId: string,
|
||||
state: any
|
||||
) => {
|
||||
await inboxServices.patchInbox(workspaceSlug, projectId, inboxId, {
|
||||
view_props: state,
|
||||
});
|
||||
};
|
||||
|
||||
export const InboxViewContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, inboxId } = router.query;
|
||||
|
||||
const { data: inboxDetails, mutate: mutateInboxDetails } = useSWR(
|
||||
workspaceSlug && projectId && inboxId ? INBOX_DETAILS(inboxId.toString()) : null,
|
||||
workspaceSlug && projectId && inboxId
|
||||
? () =>
|
||||
inboxServices.getInboxById(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const setFilters = useCallback(
|
||||
(property: Partial<IInboxFilterOptions>) => {
|
||||
Object.keys(property).forEach((key) => {
|
||||
if (property[key as keyof typeof property]?.length === 0)
|
||||
property[key as keyof typeof property] = null;
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: "SET_FILTERS",
|
||||
payload: {
|
||||
filters: {
|
||||
...state.filters,
|
||||
...property,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!workspaceSlug || !projectId || !inboxId) return;
|
||||
|
||||
const newViewProps = {
|
||||
...state,
|
||||
filters: {
|
||||
...state.filters,
|
||||
...property,
|
||||
},
|
||||
};
|
||||
|
||||
mutateInboxDetails((prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
view_props: newViewProps,
|
||||
};
|
||||
}, false);
|
||||
|
||||
saveDataToServer(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString(),
|
||||
newViewProps
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, inboxId, mutateInboxDetails, state]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({
|
||||
type: "REHYDRATE_THEME",
|
||||
payload: {
|
||||
...inboxDetails?.view_props,
|
||||
},
|
||||
});
|
||||
}, [inboxDetails]);
|
||||
|
||||
return (
|
||||
<inboxViewContext.Provider
|
||||
value={{
|
||||
filters: state.filters,
|
||||
setFilters,
|
||||
}}
|
||||
>
|
||||
<ToastAlert />
|
||||
{children}
|
||||
</inboxViewContext.Provider>
|
||||
);
|
||||
};
|
@ -633,6 +633,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
mutateModuleDetails,
|
||||
viewId,
|
||||
mutateViewDetails,
|
||||
user,
|
||||
]
|
||||
);
|
||||
|
||||
|
60
apps/app/hooks/use-inbox-view.tsx
Normal file
60
apps/app/hooks/use-inbox-view.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// contexts
|
||||
import { inboxViewContext } from "contexts/inbox-view-context";
|
||||
// services
|
||||
import inboxServices from "services/inbox.service";
|
||||
// types
|
||||
import { IInboxQueryParams } from "types";
|
||||
// fetch-keys
|
||||
import { INBOX_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
const useInboxView = () => {
|
||||
const { filters, setFilters } = useContext(inboxViewContext);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, inboxId } = router.query;
|
||||
|
||||
const params: IInboxQueryParams = {
|
||||
priority: filters?.priority ? filters?.priority.join(",") : null,
|
||||
inbox_status: filters?.inbox_status ? filters?.inbox_status.join(",") : null,
|
||||
};
|
||||
|
||||
const { data: inboxIssues, mutate: mutateInboxIssues } = useSWR(
|
||||
workspaceSlug && projectId && inboxId && params
|
||||
? INBOX_ISSUES(inboxId.toString(), params)
|
||||
: null,
|
||||
workspaceSlug && projectId && inboxId && params
|
||||
? () =>
|
||||
inboxServices.getInboxIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString(),
|
||||
params
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
let filtersLength = 0;
|
||||
Object.keys(filters ?? {}).forEach((key) => {
|
||||
const filterKey = key as keyof typeof filters;
|
||||
|
||||
if (filters[filterKey] && Array.isArray(filters[filterKey]))
|
||||
filtersLength += (filters[filterKey] ?? []).length;
|
||||
});
|
||||
|
||||
return {
|
||||
filters,
|
||||
setFilters,
|
||||
params,
|
||||
issues: inboxIssues,
|
||||
mutate: mutateInboxIssues,
|
||||
filtersLength,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export default useInboxView;
|
@ -0,0 +1,248 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
import Router, { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import inboxServices from "services/inbox.service";
|
||||
import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// layouts
|
||||
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
|
||||
// contexts
|
||||
import { InboxViewContextProvider } from "contexts/inbox-view-context";
|
||||
// components
|
||||
import {
|
||||
InboxActionHeader,
|
||||
InboxMainContent,
|
||||
SelectDuplicateInboxIssueModal,
|
||||
DeclineIssueModal,
|
||||
DeleteIssueModal,
|
||||
IssuesListSidebar,
|
||||
} from "components/inbox";
|
||||
// helper
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
// ui
|
||||
import { PrimaryButton } from "components/ui";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
// icons
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { InboxIcon } from "components/icons";
|
||||
// types
|
||||
import { IInboxIssueDetail, TInboxStatus } from "types";
|
||||
import type { NextPage } from "next";
|
||||
// fetch-keys
|
||||
import { INBOX_ISSUE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
const ProjectInbox: NextPage = () => {
|
||||
const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false);
|
||||
const [declineIssueModal, setDeclineIssueModal] = useState(false);
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query;
|
||||
|
||||
const { user } = useUserAuth();
|
||||
const { issues: inboxIssues, mutate: mutateInboxIssues } = useInboxView();
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.getProject(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!inboxIssues || !inboxIssueId) return;
|
||||
|
||||
const currentIssueIndex = inboxIssues.findIndex((issue) => issue.bridge_id === inboxIssueId);
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
Router.push({
|
||||
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
|
||||
query: {
|
||||
inboxIssueId:
|
||||
currentIssueIndex === 0
|
||||
? inboxIssues[inboxIssues.length - 1].bridge_id
|
||||
: inboxIssues[currentIssueIndex - 1].bridge_id,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "ArrowDown":
|
||||
Router.push({
|
||||
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
|
||||
query: {
|
||||
inboxIssueId:
|
||||
currentIssueIndex === inboxIssues.length - 1
|
||||
? inboxIssues[0].bridge_id
|
||||
: inboxIssues[currentIssueIndex + 1].bridge_id,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[workspaceSlug, projectId, inboxIssueId, inboxId, inboxIssues]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [onKeyDown]);
|
||||
|
||||
const markInboxStatus = async (data: TInboxStatus) => {
|
||||
if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId) return;
|
||||
|
||||
await inboxServices
|
||||
.markInboxStatus(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
inboxId.toString(),
|
||||
inboxIssues?.find((inboxIssue) => inboxIssue.bridge_id === inboxIssueId)?.bridge_id!,
|
||||
data,
|
||||
user
|
||||
)
|
||||
.then(() => {
|
||||
mutate<IInboxIssueDetail>(
|
||||
INBOX_ISSUE_DETAILS(inboxId as string, inboxIssueId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
issue_inbox: [{ ...prevData.issue_inbox[0], ...data }],
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
mutateInboxIssues(
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((i) =>
|
||||
i.bridge_id === inboxIssueId
|
||||
? { ...i, issue_inbox: [{ ...i.issue_inbox[0], ...data }] }
|
||||
: i
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<InboxViewContextProvider>
|
||||
<ProjectAuthorizationWrapper
|
||||
breadcrumbs={
|
||||
<Breadcrumbs>
|
||||
<BreadcrumbItem title="Projects" link={`/${workspaceSlug}/projects`} />
|
||||
<BreadcrumbItem
|
||||
title={`${truncateText(projectDetails?.name ?? "Project", 12)} Inbox`}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
}
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<PrimaryButton
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "c" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<SelectDuplicateInboxIssueModal
|
||||
isOpen={selectDuplicateIssue}
|
||||
onClose={() => setSelectDuplicateIssue(false)}
|
||||
value={
|
||||
inboxIssues?.find((inboxIssue) => inboxIssue.bridge_id === inboxIssueId)
|
||||
?.issue_inbox[0].duplicate_to
|
||||
}
|
||||
onSubmit={(dupIssueId: string) => {
|
||||
markInboxStatus({
|
||||
status: 2,
|
||||
duplicate_to: dupIssueId,
|
||||
}).finally(() => setSelectDuplicateIssue(false));
|
||||
}}
|
||||
/>
|
||||
<DeclineIssueModal
|
||||
isOpen={declineIssueModal}
|
||||
handleClose={() => setDeclineIssueModal(false)}
|
||||
data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)}
|
||||
/>
|
||||
<DeleteIssueModal
|
||||
isOpen={deleteIssueModal}
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)}
|
||||
/>
|
||||
<div className="flex flex-col h-full">
|
||||
<InboxActionHeader
|
||||
issue={inboxIssues?.find((issue) => issue.bridge_id === inboxIssueId)}
|
||||
currentIssueIndex={
|
||||
inboxIssues?.findIndex((issue) => issue.bridge_id === inboxIssueId) ?? 0
|
||||
}
|
||||
issueCount={inboxIssues?.length ?? 0}
|
||||
onAccept={() =>
|
||||
markInboxStatus({
|
||||
status: 1,
|
||||
})
|
||||
}
|
||||
onDecline={() => setDeclineIssueModal(true)}
|
||||
onMarkAsDuplicate={() => setSelectDuplicateIssue(true)}
|
||||
onSnooze={(date) => {
|
||||
markInboxStatus({
|
||||
status: 0,
|
||||
snoozed_till: new Date(date),
|
||||
});
|
||||
}}
|
||||
onDelete={() => setDeleteIssueModal(true)}
|
||||
/>
|
||||
<div className="grid grid-cols-4 flex-1 overflow-auto divide-x divide-brand-base">
|
||||
<IssuesListSidebar />
|
||||
<div className="col-span-3 h-full overflow-auto">
|
||||
{inboxIssueId ? (
|
||||
<InboxMainContent />
|
||||
) : (
|
||||
<div className="h-full p-4 grid place-items-center text-brand-secondary">
|
||||
<div className="grid h-full place-items-center">
|
||||
<div className="my-5 flex flex-col items-center gap-4">
|
||||
<InboxIcon height={60} width={60} />
|
||||
{inboxIssues && inboxIssues.length > 0 ? (
|
||||
<span className="text-brand-secondary">
|
||||
{inboxIssues?.length} issues found. Select an issue from the sidebar to
|
||||
view its details.
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-brand-secondary">
|
||||
No issues found. Use{" "}
|
||||
<pre className="inline rounded bg-brand-surface-2 px-2 py-1">C</pre>{" "}
|
||||
shortcut to create a new issue
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</ProjectAuthorizationWrapper>
|
||||
</InboxViewContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectInbox;
|
@ -7,10 +7,12 @@ import useSWR, { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// hooks
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// contexts
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// layouts
|
||||
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
|
||||
// components
|
||||
@ -53,6 +55,7 @@ const IssueDetailsPage: NextPage = () => {
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { user } = useUserAuth();
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
|
||||
const { data: issueDetails, mutate: mutateIssueDetails } = useSWR<IIssue | undefined>(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
|
||||
@ -105,7 +108,7 @@ const IssueDetailsPage: NextPage = () => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, issueId, projectId, mutateIssueDetails]
|
||||
[workspaceSlug, issueId, projectId, mutateIssueDetails, user]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -194,7 +197,11 @@ const IssueDetailsPage: NextPage = () => {
|
||||
</CustomMenu>
|
||||
</div>
|
||||
) : null}
|
||||
<IssueDescriptionForm issue={issueDetails} handleFormSubmit={submitChanges} />
|
||||
<IssueDescriptionForm
|
||||
issue={issueDetails}
|
||||
handleFormSubmit={submitChanges}
|
||||
isAllowed={memberRole.isMember || memberRole.isOwner}
|
||||
/>
|
||||
<div className="mt-2 space-y-2">
|
||||
<SubIssuesList parentIssue={issueDetails} user={user} />
|
||||
</div>
|
||||
@ -208,8 +215,8 @@ const IssueDetailsPage: NextPage = () => {
|
||||
</div>
|
||||
<div className="space-y-5 pt-3">
|
||||
<h3 className="text-lg text-brand-base">Comments/Activity</h3>
|
||||
<IssueActivitySection user={user} />
|
||||
<AddComment user={user} />
|
||||
<IssueActivitySection issueId={issueId as string} user={user} />
|
||||
<AddComment issueId={issueId as string} user={user} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="basis-1/3 space-y-5 border-l border-brand-base p-5">
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
import inboxService from "services/inbox.service";
|
||||
// layouts
|
||||
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
|
||||
// contexts
|
||||
@ -23,7 +25,7 @@ import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { NextPage } from "next";
|
||||
// fetch-keys
|
||||
import { PROJECT_DETAILS } from "constants/fetch-keys";
|
||||
import { PROJECT_DETAILS, INBOX_LIST } from "constants/fetch-keys";
|
||||
|
||||
const ProjectIssues: NextPage = () => {
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
@ -38,6 +40,13 @@ const ProjectIssues: NextPage = () => {
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: inboxList } = useSWR(
|
||||
workspaceSlug && projectId ? INBOX_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => inboxService.getInboxes(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<IssueViewContextProvider>
|
||||
<ProjectAuthorizationWrapper
|
||||
@ -59,6 +68,23 @@ const ProjectIssues: NextPage = () => {
|
||||
>
|
||||
Analytics
|
||||
</SecondaryButton>
|
||||
{projectDetails && projectDetails.inbox_view && (
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`}>
|
||||
<a>
|
||||
<SecondaryButton
|
||||
className="relative !py-1.5 rounded-md font-normal text-brand-secondary"
|
||||
outline
|
||||
>
|
||||
<span>Inbox</span>
|
||||
{inboxList && inboxList?.[0]?.pending_issue_count !== 0 && (
|
||||
<span className="absolute -top-1 -right-1 h-4 w-4 rounded-full text-brand-base bg-brand-surface-2 border border-brand-base">
|
||||
{inboxList?.[0]?.pending_issue_count}
|
||||
</span>
|
||||
)}
|
||||
</SecondaryButton>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
<PrimaryButton
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
|
@ -18,7 +18,7 @@ import { SettingsHeader } from "components/project";
|
||||
import { SecondaryButton, ToggleSwitch } from "components/ui";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
// icons
|
||||
import { ContrastIcon, PeopleGroupIcon, ViewListIcon } from "components/icons";
|
||||
import { ContrastIcon, PeopleGroupIcon, ViewListIcon, InboxIcon } from "components/icons";
|
||||
import { DocumentTextIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IFavoriteProject, IProject } from "types";
|
||||
@ -55,6 +55,13 @@ const featuresList = [
|
||||
icon: <DocumentTextIcon color="#fcbe1d" width={28} height={28} className="flex-shrink-0" />,
|
||||
property: "page_view",
|
||||
},
|
||||
{
|
||||
title: "Inbox",
|
||||
description:
|
||||
"Inbox are enabled for all the projects in this workspace. Access it from the issues views page.",
|
||||
icon: <InboxIcon color="#fcbe1d" width={24} height={24} className="flex-shrink-0" />,
|
||||
property: "inbox_view",
|
||||
},
|
||||
];
|
||||
|
||||
const getEventType = (feature: string, toggle: boolean): MiscellaneousEventType => {
|
||||
@ -67,8 +74,10 @@ const getEventType = (feature: string, toggle: boolean): MiscellaneousEventType
|
||||
return toggle ? "TOGGLE_VIEW_ON" : "TOGGLE_VIEW_OFF";
|
||||
case "Pages":
|
||||
return toggle ? "TOGGLE_PAGES_ON" : "TOGGLE_PAGES_OFF";
|
||||
case "Inbox":
|
||||
return toggle ? "TOGGLE_INBOX_ON" : "TOGGLE_INBOX_OFF";
|
||||
default:
|
||||
return toggle ? "TOGGLE_PAGES_ON" : "TOGGLE_PAGES_OFF";
|
||||
throw new Error("Invalid feature");
|
||||
}
|
||||
};
|
||||
|
||||
@ -195,9 +204,10 @@ const FeaturesSettings: NextPage = () => {
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
projectName: projectDetails?.name,
|
||||
},
|
||||
!projectDetails?.[feature.property as keyof IProject]
|
||||
? getEventType(feature.title, true)
|
||||
: getEventType(feature.title, false),
|
||||
getEventType(
|
||||
feature.title,
|
||||
!projectDetails?.[feature.property as keyof IProject]
|
||||
),
|
||||
user
|
||||
);
|
||||
handleSubmit({
|
||||
|
57
apps/app/public/empty-state/empty-inbox.svg
Normal file
57
apps/app/public/empty-state/empty-inbox.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 88 KiB |
183
apps/app/services/inbox.service.ts
Normal file
183
apps/app/services/inbox.service.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import APIService from "services/api.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
|
||||
const trackEvent =
|
||||
process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1";
|
||||
|
||||
// types
|
||||
import type {
|
||||
IInboxIssue,
|
||||
IInbox,
|
||||
TInboxStatus,
|
||||
IInboxIssueDetail,
|
||||
ICurrentUserResponse,
|
||||
IInboxFilterOptions,
|
||||
IInboxQueryParams,
|
||||
} from "types";
|
||||
|
||||
class InboxServices extends APIService {
|
||||
constructor() {
|
||||
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||
}
|
||||
|
||||
async getInboxes(workspaceSlug: string, projectId: string): Promise<IInbox[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getInboxById(workspaceSlug: string, projectId: string, inboxId: string): Promise<IInbox> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async patchInbox(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
inboxId: string,
|
||||
data: Partial<IInbox>
|
||||
): Promise<any> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`,
|
||||
data
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getInboxIssues(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
inboxId: string,
|
||||
params?: IInboxQueryParams
|
||||
): Promise<IInboxIssue[]> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/`,
|
||||
{ params }
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getInboxIssueById(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
inboxId: string,
|
||||
inboxIssueId: string
|
||||
): Promise<IInboxIssueDetail> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInboxIssue(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
inboxId: string,
|
||||
inboxIssueId: string,
|
||||
user: ICurrentUserResponse | undefined
|
||||
): Promise<any> {
|
||||
return this.delete(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`
|
||||
)
|
||||
.then((response) => {
|
||||
if (trackEvent)
|
||||
trackEventServices.trackInboxEvent(response?.data, "INBOX_ISSUE_DELETE", user);
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async markInboxStatus(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
inboxId: string,
|
||||
inboxIssueId: string,
|
||||
data: TInboxStatus,
|
||||
user: ICurrentUserResponse | undefined
|
||||
): Promise<IInboxIssue> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`,
|
||||
data
|
||||
)
|
||||
.then((response) => {
|
||||
const action =
|
||||
data.status === -1
|
||||
? "INBOX_ISSUE_REJECTED"
|
||||
: data.status === 0
|
||||
? "INBOX_ISSUE_SNOOZED"
|
||||
: data.status === 1
|
||||
? "INBOX_ISSUE_ACCEPTED"
|
||||
: "INBOX_ISSUE_DUPLICATED";
|
||||
if (trackEvent) trackEventServices.trackInboxEvent(response?.data, action, user);
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async patchInboxIssue(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
inboxId: string,
|
||||
inboxIssueId: string,
|
||||
data: { issue: Partial<IInboxIssue> },
|
||||
user: ICurrentUserResponse | undefined
|
||||
): Promise<IInboxIssue> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`,
|
||||
data
|
||||
)
|
||||
.then((response) => {
|
||||
if (trackEvent)
|
||||
trackEventServices.trackInboxEvent(response?.data, "INBOX_ISSUE_UPDATE", user);
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async createInboxIssue(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
inboxId: string,
|
||||
data: any,
|
||||
user: ICurrentUserResponse | undefined
|
||||
): Promise<IInboxIssue> {
|
||||
return this.post(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/`,
|
||||
data
|
||||
)
|
||||
.then((response) => {
|
||||
if (trackEvent)
|
||||
trackEventServices.trackInboxEvent(response?.data, "INBOX_ISSUE_CREATE", user);
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const inboxServices = new InboxServices();
|
||||
|
||||
export default inboxServices;
|
@ -52,17 +52,15 @@ type IssueCommentEventType =
|
||||
| "ISSUE_COMMENT_UPDATE"
|
||||
| "ISSUE_COMMENT_DELETE";
|
||||
|
||||
export type MiscellaneousEventType =
|
||||
| "TOGGLE_CYCLE_ON"
|
||||
| "TOGGLE_CYCLE_OFF"
|
||||
| "TOGGLE_MODULE_ON"
|
||||
| "TOGGLE_MODULE_OFF"
|
||||
| "TOGGLE_VIEW_ON"
|
||||
| "TOGGLE_VIEW_OFF"
|
||||
| "TOGGLE_PAGES_ON"
|
||||
| "TOGGLE_PAGES_OFF"
|
||||
| "TOGGLE_STATE_ON"
|
||||
| "TOGGLE_STATE_OFF";
|
||||
type Toggle =
|
||||
| "TOGGLE_CYCLE"
|
||||
| "TOGGLE_MODULE"
|
||||
| "TOGGLE_VIEW"
|
||||
| "TOGGLE_PAGES"
|
||||
| "TOGGLE_STATE"
|
||||
| "TOGGLE_INBOX";
|
||||
|
||||
export type MiscellaneousEventType = `${Toggle}_ON` | `${Toggle}_OFF`;
|
||||
|
||||
type IntegrationEventType = "ADD_WORKSPACE_INTEGRATION" | "REMOVE_WORKSPACE_INTEGRATION";
|
||||
|
||||
@ -80,6 +78,18 @@ type GptEventType = "ASK_GPT" | "USE_GPT_RESPONSE_IN_ISSUE" | "USE_GPT_RESPONSE_
|
||||
|
||||
type IssueEstimateEventType = "ESTIMATE_CREATE" | "ESTIMATE_UPDATE" | "ESTIMATE_DELETE";
|
||||
|
||||
type InboxEventType =
|
||||
| "INBOX_CREATE"
|
||||
| "INBOX_UPDATE"
|
||||
| "INBOX_DELETE"
|
||||
| "INBOX_ISSUE_CREATE"
|
||||
| "INBOX_ISSUE_UPDATE"
|
||||
| "INBOX_ISSUE_DELETE"
|
||||
| "INBOX_ISSUE_DUPLICATED"
|
||||
| "INBOX_ISSUE_ACCEPTED"
|
||||
| "INBOX_ISSUE_SNOOZED"
|
||||
| "INBOX_ISSUE_REJECTED";
|
||||
|
||||
type ImporterEventType =
|
||||
| "GITHUB_IMPORTER_CREATE"
|
||||
| "GITHUB_IMPORTER_DELETE"
|
||||
@ -740,6 +750,38 @@ class TrackEventServices extends APIService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: add types to the data
|
||||
async trackInboxEvent(
|
||||
data: any,
|
||||
eventName: InboxEventType,
|
||||
user: ICurrentUserResponse | undefined
|
||||
): Promise<any> {
|
||||
let payload: any;
|
||||
if (eventName !== "INBOX_DELETE")
|
||||
payload = {
|
||||
issue: data?.issue?.id,
|
||||
inbox: data?.id,
|
||||
workspaceId: data?.issue?.workspace_detail?.id,
|
||||
workspaceName: data?.issue?.workspace_detail?.name,
|
||||
workspaceSlug: data?.issue?.workspace_detail?.slug,
|
||||
projectId: data?.issue?.project_detail?.id,
|
||||
projectName: data?.issue?.project_detail?.name,
|
||||
};
|
||||
else payload = data;
|
||||
|
||||
return this.request({
|
||||
url: "/api/track-event",
|
||||
method: "POST",
|
||||
data: {
|
||||
eventName,
|
||||
extra: {
|
||||
...payload,
|
||||
},
|
||||
user: user,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const trackEventServices = new TrackEventServices();
|
||||
|
85
apps/app/types/inbox.d.ts
vendored
Normal file
85
apps/app/types/inbox.d.ts
vendored
Normal file
@ -0,0 +1,85 @@
|
||||
import { IIssue, IIssueFilterOptions, IIssueLabels } from "./issues";
|
||||
import type { IProjectLite } from "./projects";
|
||||
import { IState } from "./state";
|
||||
import { IUserLite } from "./users";
|
||||
|
||||
export interface IInboxIssue extends Partial<IIssue> {
|
||||
bridge_id: string;
|
||||
issue_inbox: {
|
||||
duplicate_to: string | null;
|
||||
snoozed_till: Date | null;
|
||||
source: string;
|
||||
status: -2 | -1 | 0 | 1 | 2;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface IInboxIssueDetail extends IIssue {
|
||||
id: string;
|
||||
project_detail: IProjectLite;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
issue_inbox: {
|
||||
duplicate_to: string | null;
|
||||
id: string;
|
||||
snoozed_till: Date | null;
|
||||
source: string;
|
||||
status: -2 | -1 | 0 | 1 | 2;
|
||||
}[];
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
project: string;
|
||||
workspace: string;
|
||||
}
|
||||
export interface IInbox {
|
||||
id: string;
|
||||
project_detail: IProjectLite;
|
||||
pending_issue_count: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
name: string;
|
||||
description: string;
|
||||
is_default: boolean;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
project: string;
|
||||
view_props: { filters: IInboxFilterOptions };
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
interface StatePending {
|
||||
readonly status: -2;
|
||||
}
|
||||
interface StatusReject {
|
||||
status: -1;
|
||||
}
|
||||
|
||||
interface StatusSnoozed {
|
||||
status: 0;
|
||||
snoozed_till: Date;
|
||||
}
|
||||
|
||||
interface StatusAccepted {
|
||||
status: 1;
|
||||
}
|
||||
|
||||
interface StatusDuplicate {
|
||||
status: 2;
|
||||
duplicate_to: string;
|
||||
}
|
||||
|
||||
export type TInboxStatus =
|
||||
| StatusReject
|
||||
| StatusSnoozed
|
||||
| StatusAccepted
|
||||
| StatusDuplicate
|
||||
| StatePending;
|
||||
|
||||
export interface IInboxFilterOptions {
|
||||
priority: string[] | null;
|
||||
inbox_status: number[] | null;
|
||||
}
|
||||
|
||||
export interface IInboxQueryParams {
|
||||
priority: string | null;
|
||||
inbox_status: string | null;
|
||||
}
|
1
apps/app/types/index.d.ts
vendored
1
apps/app/types/index.d.ts
vendored
@ -12,6 +12,7 @@ export * from "./pages";
|
||||
export * from "./ai";
|
||||
export * from "./estimate";
|
||||
export * from "./importer";
|
||||
export * from "./inbox";
|
||||
export * from "./analytics";
|
||||
export * from "./calendar";
|
||||
|
||||
|
8
apps/app/types/issues.d.ts
vendored
8
apps/app/types/issues.d.ts
vendored
@ -79,7 +79,7 @@ export interface IIssue {
|
||||
blocks_list: string[];
|
||||
bridge_id?: string | null;
|
||||
completed_at: Date;
|
||||
created_at: Date;
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
cycle: string | null;
|
||||
cycle_id: string | null;
|
||||
@ -100,7 +100,9 @@ export interface IIssue {
|
||||
url: string;
|
||||
}[];
|
||||
issue_module: IIssueModule | null;
|
||||
labels: string[];
|
||||
label_details: any[];
|
||||
labels_list: string[];
|
||||
links_list: IIssueLink[];
|
||||
link_count: number;
|
||||
module: string | null;
|
||||
@ -119,12 +121,10 @@ export interface IIssue {
|
||||
state_detail: IState;
|
||||
sub_issues_count: number;
|
||||
target_date: string | null;
|
||||
updated_at: Date;
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
workspace: string;
|
||||
workspace_detail: IWorkspaceLite;
|
||||
labels: any[];
|
||||
labels_list: string[];
|
||||
}
|
||||
|
||||
export interface ISubIssuesState {
|
||||
|
4
apps/app/types/projects.d.ts
vendored
4
apps/app/types/projects.d.ts
vendored
@ -13,6 +13,10 @@ export interface IProject {
|
||||
created_by: string;
|
||||
cover_image: string | null;
|
||||
cycle_view: boolean;
|
||||
issue_views_view: boolean;
|
||||
module_view: boolean;
|
||||
page_view: boolean;
|
||||
inbox_view: boolean;
|
||||
default_assignee: IUser | string | null;
|
||||
description: string;
|
||||
emoji: string | null;
|
||||
|
Loading…
Reference in New Issue
Block a user