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:
pablohashescobar 2023-06-16 18:57:17 +05:30 committed by GitHub
parent 963ccd808d
commit e9a0eb87cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 3712 additions and 737 deletions

View File

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

View File

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

View File

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

View File

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

View 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__"

View File

@ -41,6 +41,7 @@ class IssueFlatSerializer(BaseSerializer):
"id",
"name",
"description",
"description_html",
"priority",
"start_date",
"target_date",

View File

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

View File

@ -134,6 +134,7 @@ from .estimate import (
from .release import ReleaseNotesEndpoint
from .inbox import InboxViewSet, InboxIssueViewSet
from .analytic import (
AnalyticsEndpoint,
AnalyticViewViewset,

View File

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

View File

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

View File

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

View 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,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -276,6 +276,7 @@ export const IssuesView: React.FC<Props> = ({
handleDeleteIssue,
params,
states,
user,
]
);

View File

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

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

View File

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

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

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

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

View 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]),
})),
],
},
]}
/>
);

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

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

View 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;
};

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -67,7 +67,7 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
console.log(error);
});
},
[workspaceSlug, projectId]
[workspaceSlug, projectId, user]
);
const handleCopyText = () => {

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -633,6 +633,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
mutateModuleDetails,
viewId,
mutateViewDetails,
user,
]
);

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB

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

View File

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

View File

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

View File

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

View File

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