[WEB-406] chore: project inbox revamp (#4141)
* chore: removed inbox id * fix: inbox changes * chore: resolved merge conflicts * chore: inbox issue response changes * chore: inbox issue filters * fix: inbox implementation revamp * fix: type fixes * fix: pagination implementation * fix: inbox fixes * fix: pagination fixes * fix: inbox Issues pagination fixes * chore: triage state change * fix: inbox fixes * chore: filtering using boolean * chore: total results in the pagination * fix: inbox main content changes * fix: develop pull fixes * chore: resolved build erros in inbox issues * dev: fix migrations * chore: module, labels and assignee in inbox * chore: inbox issue order by * chore: inbox filters * chore: inbox ui revamp * chore: inbox type updated * chore: updated filters * chore: updated filter menmbers and date types in inbox issue filter * chore: inbox issue filter updated * chore: updated date filter in the inbox issue filter * chore: moved the current tab state from local state to store * chore: updated the filter and fetch request in the inbox issues * chore: updated tab change handler * chore: handled isEmpty in the issue filters query params * chore: inbox sidebar updated * chore: enabled create inbox issue in mobx * chore: replaced the key inbox_status to status * chore: inbox sidebar pagination * chore: updated inbox issue services * chore: inbox sidebar total count indicator * chore: create inbox issue updated * chore: updated inbox issue sidebar layout * chore: rendering issue detail in inbox issue * chore: inbox issue content updated * chore: create inbox issue modal description improvement * fix: updated delete functionality in inbox store * chore: updated multiple inbox issue creation * chore: handled loading, empty states and inbox user access permissions * chore: updated rendering issues in the sidebar * chore: inbox sidebar label improvement * chore: handled empty states * chore: disabled inbox empty state added * chore: module, labels and assignee in list endpoint * chore: labels in list endpoint * chore: inboc issue serializer * chore: representation in serializer * chore: super function * chore: inbox empty state updated * chore: implemented applied filters * chore: inbox empty state updated * chore: update date formats in applied filters * chore: inbox skeleton updated * chore: ui changes in the siebar list item * chore: removed the module and cycle ids * chore: inbox sidebar tab * chore: inbox actions * chore: updated inbox issue header actions * chore: updated inbox issue code cleanup * chore: loader improvement * chore: inbox sidebar improvement * chore: inbox sidebar empty state flicker * fix: inbox issue delete operation * chore: inbox issue title and description update indicator added * fix: resolved issue property rendering in initial load * chore: inbox sidebar and detail header improvement * fix: handling selected filter in the issue filters and applied filters * chore: inbox issue detail improvement * chore: inbox issue label updated * chore: inbox issue sidebar improvement * fix: handling issue description update when we move between the issues in inbox * chore: removed inbox issue helpers file * chore: boolean checked * chore: resolved file change requests --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com> Co-authored-by: pablohashescobar <nikhilschacko@gmail.com> Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
@ -135,10 +135,11 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
|||||||
# Create or get state
|
# Create or get state
|
||||||
state, _ = State.objects.get_or_create(
|
state, _ = State.objects.get_or_create(
|
||||||
name="Triage",
|
name="Triage",
|
||||||
group="backlog",
|
group="triage",
|
||||||
description="Default state for managing all Inbox Issues",
|
description="Default state for managing all Inbox Issues",
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
color="#ff7700",
|
color="#ff7700",
|
||||||
|
is_triage=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# create an issue
|
# create an issue
|
||||||
@ -299,7 +300,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update the issue state only if it is in triage state
|
# Update the issue state only if it is in triage state
|
||||||
if issue.state.name == "Triage":
|
if issue.state.is_triage:
|
||||||
# Move to default state
|
# Move to default state
|
||||||
state = State.objects.filter(
|
state = State.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
|
@ -291,10 +291,11 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
|
|||||||
# Create the triage state in Backlog group
|
# Create the triage state in Backlog group
|
||||||
State.objects.get_or_create(
|
State.objects.get_or_create(
|
||||||
name="Triage",
|
name="Triage",
|
||||||
group="backlog",
|
group="triage",
|
||||||
description="Default state for managing all Inbox Issues",
|
description="Default state for managing all Inbox Issues",
|
||||||
project_id=pk,
|
project_id=pk,
|
||||||
color="#ff7700",
|
color="#ff7700",
|
||||||
|
is_triage=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
project = (
|
project = (
|
||||||
|
@ -29,8 +29,8 @@ class StateAPIEndpoint(BaseAPIView):
|
|||||||
project__project_projectmember__member=self.request.user,
|
project__project_projectmember__member=self.request.user,
|
||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
|
.filter(is_triage=False)
|
||||||
.filter(project__archived_at__isnull=True)
|
.filter(project__archived_at__isnull=True)
|
||||||
.filter(~Q(name="Triage"))
|
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.distinct()
|
.distinct()
|
||||||
@ -106,7 +106,7 @@ class StateAPIEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
def delete(self, request, slug, project_id, state_id):
|
def delete(self, request, slug, project_id, state_id):
|
||||||
state = State.objects.get(
|
state = State.objects.get(
|
||||||
~Q(name="Triage"),
|
is_triage=False,
|
||||||
pk=state_id,
|
pk=state_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
|
@ -59,6 +59,7 @@ from .issue import (
|
|||||||
IssueFlatSerializer,
|
IssueFlatSerializer,
|
||||||
IssueStateSerializer,
|
IssueStateSerializer,
|
||||||
IssueLinkSerializer,
|
IssueLinkSerializer,
|
||||||
|
IssueInboxSerializer,
|
||||||
IssueLiteSerializer,
|
IssueLiteSerializer,
|
||||||
IssueAttachmentSerializer,
|
IssueAttachmentSerializer,
|
||||||
IssueSubscriberSerializer,
|
IssueSubscriberSerializer,
|
||||||
@ -107,6 +108,7 @@ from .inbox import (
|
|||||||
InboxIssueSerializer,
|
InboxIssueSerializer,
|
||||||
IssueStateInboxSerializer,
|
IssueStateInboxSerializer,
|
||||||
InboxIssueLiteSerializer,
|
InboxIssueLiteSerializer,
|
||||||
|
InboxIssueDetailSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .analytic import AnalyticViewSerializer
|
from .analytic import AnalyticViewSerializer
|
||||||
|
@ -3,7 +3,11 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
from .issue import IssueFlatSerializer, LabelLiteSerializer
|
from .issue import (
|
||||||
|
IssueInboxSerializer,
|
||||||
|
LabelLiteSerializer,
|
||||||
|
IssueDetailSerializer,
|
||||||
|
)
|
||||||
from .project import ProjectLiteSerializer
|
from .project import ProjectLiteSerializer
|
||||||
from .state import StateLiteSerializer
|
from .state import StateLiteSerializer
|
||||||
from .user import UserLiteSerializer
|
from .user import UserLiteSerializer
|
||||||
@ -24,17 +28,58 @@ class InboxSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class InboxIssueSerializer(BaseSerializer):
|
class InboxIssueSerializer(BaseSerializer):
|
||||||
issue_detail = IssueFlatSerializer(source="issue", read_only=True)
|
issue = IssueInboxSerializer(read_only=True)
|
||||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InboxIssue
|
model = InboxIssue
|
||||||
fields = "__all__"
|
fields = [
|
||||||
|
"id",
|
||||||
|
"status",
|
||||||
|
"duplicate_to",
|
||||||
|
"snoozed_till",
|
||||||
|
"source",
|
||||||
|
"issue",
|
||||||
|
"created_by",
|
||||||
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"project",
|
"project",
|
||||||
"workspace",
|
"workspace",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
# Pass the annotated fields to the Issue instance if they exist
|
||||||
|
if hasattr(instance, "label_ids"):
|
||||||
|
instance.issue.label_ids = instance.label_ids
|
||||||
|
return super().to_representation(instance)
|
||||||
|
|
||||||
|
|
||||||
|
class InboxIssueDetailSerializer(BaseSerializer):
|
||||||
|
issue = IssueDetailSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = InboxIssue
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"status",
|
||||||
|
"duplicate_to",
|
||||||
|
"snoozed_till",
|
||||||
|
"source",
|
||||||
|
"issue",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"project",
|
||||||
|
"workspace",
|
||||||
|
]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
# Pass the annotated fields to the Issue instance if they exist
|
||||||
|
if hasattr(instance, "assignee_ids"):
|
||||||
|
instance.issue.assignee_ids = instance.assignee_ids
|
||||||
|
if hasattr(instance, "label_ids"):
|
||||||
|
instance.issue.label_ids = instance.label_ids
|
||||||
|
|
||||||
|
return super().to_representation(instance)
|
||||||
|
|
||||||
|
|
||||||
class InboxIssueLiteSerializer(BaseSerializer):
|
class InboxIssueLiteSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -620,6 +620,26 @@ class IssueStateSerializer(DynamicBaseSerializer):
|
|||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class IssueInboxSerializer(DynamicBaseSerializer):
|
||||||
|
label_ids = serializers.ListField(
|
||||||
|
child=serializers.UUIDField(),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Issue
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"priority",
|
||||||
|
"sequence_id",
|
||||||
|
"project_id",
|
||||||
|
"created_at",
|
||||||
|
"label_ids",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
class IssueSerializer(DynamicBaseSerializer):
|
class IssueSerializer(DynamicBaseSerializer):
|
||||||
# ids
|
# ids
|
||||||
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||||
@ -688,7 +708,7 @@ class IssueLiteSerializer(DynamicBaseSerializer):
|
|||||||
|
|
||||||
class IssueDetailSerializer(IssueSerializer):
|
class IssueDetailSerializer(IssueSerializer):
|
||||||
description_html = serializers.CharField()
|
description_html = serializers.CharField()
|
||||||
is_subscribed = serializers.BooleanField()
|
is_subscribed = serializers.BooleanField(read_only=True)
|
||||||
|
|
||||||
class Meta(IssueSerializer.Meta):
|
class Meta(IssueSerializer.Meta):
|
||||||
fields = IssueSerializer.Meta.fields + [
|
fields = IssueSerializer.Meta.fields + [
|
||||||
|
@ -30,7 +30,7 @@ urlpatterns = [
|
|||||||
name="inbox",
|
name="inbox",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/",
|
||||||
InboxIssueViewSet.as_view(
|
InboxIssueViewSet.as_view(
|
||||||
{
|
{
|
||||||
"get": "list",
|
"get": "list",
|
||||||
@ -40,7 +40,7 @@ urlpatterns = [
|
|||||||
name="inbox-issue",
|
name="inbox-issue",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:issue_id>/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:issue_id>/",
|
||||||
InboxIssueViewSet.as_view(
|
InboxIssueViewSet.as_view(
|
||||||
{
|
{
|
||||||
"get": "retrieve",
|
"get": "retrieve",
|
||||||
|
@ -22,18 +22,17 @@ from plane.db.models import (
|
|||||||
InboxIssue,
|
InboxIssue,
|
||||||
Issue,
|
Issue,
|
||||||
State,
|
State,
|
||||||
|
Workspace,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
ProjectMember,
|
ProjectMember,
|
||||||
IssueReaction,
|
|
||||||
IssueSubscriber,
|
|
||||||
)
|
)
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
IssueCreateSerializer,
|
IssueCreateSerializer,
|
||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
InboxSerializer,
|
InboxSerializer,
|
||||||
InboxIssueSerializer,
|
InboxIssueSerializer,
|
||||||
IssueDetailSerializer,
|
InboxIssueDetailSerializer,
|
||||||
)
|
)
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
@ -64,13 +63,20 @@ class InboxViewSet(BaseViewSet):
|
|||||||
.select_related("workspace", "project")
|
.select_related("workspace", "project")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list(self, request, slug, project_id):
|
||||||
|
inbox = self.get_queryset().first()
|
||||||
|
return Response(
|
||||||
|
InboxSerializer(inbox).data,
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, pk):
|
def destroy(self, request, slug, project_id, pk):
|
||||||
inbox = Inbox.objects.get(
|
inbox = Inbox.objects.filter(
|
||||||
workspace__slug=slug, project_id=project_id, pk=pk
|
workspace__slug=slug, project_id=project_id, pk=pk
|
||||||
)
|
).first()
|
||||||
# Handle default inbox delete
|
# Handle default inbox delete
|
||||||
if inbox.is_default:
|
if inbox.is_default:
|
||||||
return Response(
|
return Response(
|
||||||
@ -98,7 +104,6 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
Issue.objects.filter(
|
Issue.objects.filter(
|
||||||
project_id=self.kwargs.get("project_id"),
|
project_id=self.kwargs.get("project_id"),
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
issue_inbox__inbox_id=self.kwargs.get("inbox_id"),
|
|
||||||
)
|
)
|
||||||
.select_related("workspace", "project", "state", "parent")
|
.select_related("workspace", "project", "state", "parent")
|
||||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||||
@ -162,51 +167,50 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
def list(self, request, slug, project_id, inbox_id):
|
def list(self, request, slug, project_id):
|
||||||
filters = issue_filters(request.query_params, "GET")
|
workspace = Workspace.objects.filter(slug=slug).first()
|
||||||
issue_queryset = (
|
inbox_id = Inbox.objects.filter(
|
||||||
self.get_queryset()
|
workspace_id=workspace.id, project_id=project_id
|
||||||
.filter(**filters)
|
).first()
|
||||||
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
|
filters = issue_filters(request.GET, "GET", "issue__")
|
||||||
|
inbox_issue = (
|
||||||
|
InboxIssue.objects.filter(
|
||||||
|
inbox_id=inbox_id.id, project_id=project_id, **filters
|
||||||
)
|
)
|
||||||
if self.expand:
|
.select_related("issue")
|
||||||
issues = IssueSerializer(
|
.prefetch_related(
|
||||||
issue_queryset, expand=self.expand, many=True
|
"issue__labels",
|
||||||
).data
|
|
||||||
else:
|
|
||||||
issues = issue_queryset.values(
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"state_id",
|
|
||||||
"sort_order",
|
|
||||||
"completed_at",
|
|
||||||
"estimate_point",
|
|
||||||
"priority",
|
|
||||||
"start_date",
|
|
||||||
"target_date",
|
|
||||||
"sequence_id",
|
|
||||||
"project_id",
|
|
||||||
"parent_id",
|
|
||||||
"cycle_id",
|
|
||||||
"module_ids",
|
|
||||||
"label_ids",
|
|
||||||
"assignee_ids",
|
|
||||||
"sub_issues_count",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
"created_by",
|
|
||||||
"updated_by",
|
|
||||||
"attachment_count",
|
|
||||||
"link_count",
|
|
||||||
"is_draft",
|
|
||||||
"archived_at",
|
|
||||||
)
|
)
|
||||||
return Response(
|
.annotate(
|
||||||
issues,
|
label_ids=Coalesce(
|
||||||
status=status.HTTP_200_OK,
|
ArrayAgg(
|
||||||
|
"issue__labels__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue__labels__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(request.GET.get("order_by", "-issue__created_at"))
|
||||||
|
# inbox status filter
|
||||||
|
inbox_status = [
|
||||||
|
item
|
||||||
|
for item in request.GET.get("status", "-2").split(",")
|
||||||
|
if item != "null"
|
||||||
|
]
|
||||||
|
if inbox_status:
|
||||||
|
inbox_issue = inbox_issue.filter(status__in=inbox_status)
|
||||||
|
|
||||||
|
return self.paginate(
|
||||||
|
request=request,
|
||||||
|
queryset=(inbox_issue),
|
||||||
|
on_results=lambda inbox_issues: InboxIssueSerializer(
|
||||||
|
inbox_issues,
|
||||||
|
many=True,
|
||||||
|
).data,
|
||||||
)
|
)
|
||||||
|
|
||||||
def create(self, request, slug, project_id, inbox_id):
|
def create(self, request, slug, project_id):
|
||||||
if not request.data.get("issue", {}).get("name", False):
|
if not request.data.get("issue", {}).get("name", False):
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Name is required"},
|
{"error": "Name is required"},
|
||||||
@ -229,10 +233,11 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
# Create or get state
|
# Create or get state
|
||||||
state, _ = State.objects.get_or_create(
|
state, _ = State.objects.get_or_create(
|
||||||
name="Triage",
|
name="Triage",
|
||||||
group="backlog",
|
group="triage",
|
||||||
description="Default state for managing all Inbox Issues",
|
description="Default state for managing all Inbox Issues",
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
color="#ff7700",
|
color="#ff7700",
|
||||||
|
is_triage=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# create an issue
|
# create an issue
|
||||||
@ -259,19 +264,25 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
notification=True,
|
notification=True,
|
||||||
origin=request.META.get("HTTP_ORIGIN"),
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
|
workspace = Workspace.objects.filter(slug=slug).first()
|
||||||
|
inbox_id = Inbox.objects.filter(
|
||||||
|
workspace_id=workspace.id, project_id=project_id
|
||||||
|
).first()
|
||||||
# create an inbox issue
|
# create an inbox issue
|
||||||
InboxIssue.objects.create(
|
inbox_issue = InboxIssue.objects.create(
|
||||||
inbox_id=inbox_id,
|
inbox_id=inbox_id.id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
issue=issue,
|
issue=issue,
|
||||||
source=request.data.get("source", "in-app"),
|
source=request.data.get("source", "in-app"),
|
||||||
)
|
)
|
||||||
|
serializer = InboxIssueDetailSerializer(inbox_issue)
|
||||||
issue = self.get_queryset().filter(pk=issue.id).first()
|
|
||||||
serializer = IssueSerializer(issue, expand=self.expand)
|
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def partial_update(self, request, slug, project_id, inbox_id, issue_id):
|
def partial_update(self, request, slug, project_id, issue_id):
|
||||||
|
workspace = Workspace.objects.filter(slug=slug).first()
|
||||||
|
inbox_id = Inbox.objects.filter(
|
||||||
|
workspace_id=workspace.id, project_id=project_id
|
||||||
|
).first()
|
||||||
inbox_issue = InboxIssue.objects.get(
|
inbox_issue = InboxIssue.objects.get(
|
||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
@ -374,7 +385,7 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update the issue state only if it is in triage state
|
# Update the issue state only if it is in triage state
|
||||||
if issue.state.name == "Triage":
|
if issue.state.is_triage:
|
||||||
# Move to default state
|
# Move to default state
|
||||||
state = State.objects.filter(
|
state = State.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
@ -384,60 +395,60 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
if state is not None:
|
if state is not None:
|
||||||
issue.state = state
|
issue.state = state
|
||||||
issue.save()
|
issue.save()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
serializer = InboxIssueDetailSerializer(inbox_issue).data
|
||||||
|
return Response(serializer, status=status.HTTP_200_OK)
|
||||||
return Response(
|
return Response(
|
||||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
issue = self.get_queryset().filter(pk=issue_id).first()
|
serializer = InboxIssueDetailSerializer(inbox_issue).data
|
||||||
serializer = IssueSerializer(issue, expand=self.expand)
|
return Response(serializer, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
|
def retrieve(self, request, slug, project_id, issue_id):
|
||||||
issue = (
|
workspace = Workspace.objects.filter(slug=slug).first()
|
||||||
self.get_queryset()
|
inbox_id = Inbox.objects.filter(
|
||||||
.filter(pk=issue_id)
|
workspace_id=workspace.id, project_id=project_id
|
||||||
|
).first()
|
||||||
|
inbox_issue = (
|
||||||
|
InboxIssue.objects.select_related("issue")
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
"issue__labels",
|
||||||
"issue_reactions",
|
"issue__assignees",
|
||||||
queryset=IssueReaction.objects.select_related(
|
|
||||||
"issue", "actor"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_attachment",
|
|
||||||
queryset=IssueAttachment.objects.select_related("issue"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_link",
|
|
||||||
queryset=IssueLink.objects.select_related("created_by"),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
is_subscribed=Exists(
|
label_ids=Coalesce(
|
||||||
IssueSubscriber.objects.filter(
|
ArrayAgg(
|
||||||
workspace__slug=slug,
|
"issue__labels__id",
|
||||||
project_id=project_id,
|
distinct=True,
|
||||||
issue_id=OuterRef("pk"),
|
filter=~Q(issue__labels__id__isnull=True),
|
||||||
subscriber=request.user,
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue__assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(issue__assignees__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
inbox_id=inbox_id.id, issue_id=issue_id, project_id=project_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
issue = InboxIssueDetailSerializer(inbox_issue).data
|
||||||
).first()
|
|
||||||
if issue is None:
|
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Requested object was not found"},
|
issue,
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = IssueDetailSerializer(issue)
|
def destroy(self, request, slug, project_id, issue_id):
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
workspace = Workspace.objects.filter(slug=slug).first()
|
||||||
|
inbox_id = Inbox.objects.filter(
|
||||||
def destroy(self, request, slug, project_id, inbox_id, issue_id):
|
workspace_id=workspace.id, project_id=project_id
|
||||||
|
).first()
|
||||||
inbox_issue = InboxIssue.objects.get(
|
inbox_issue = InboxIssue.objects.get(
|
||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
|
@ -393,10 +393,11 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
|||||||
# Create the triage state in Backlog group
|
# Create the triage state in Backlog group
|
||||||
State.objects.get_or_create(
|
State.objects.get_or_create(
|
||||||
name="Triage",
|
name="Triage",
|
||||||
group="backlog",
|
group="triage",
|
||||||
description="Default state for managing all Inbox Issues",
|
description="Default state for managing all Inbox Issues",
|
||||||
project_id=pk,
|
project_id=pk,
|
||||||
color="#ff7700",
|
color="#ff7700",
|
||||||
|
is_triage=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
project = (
|
project = (
|
||||||
|
@ -35,7 +35,7 @@ class StateViewSet(BaseViewSet):
|
|||||||
project__project_projectmember__is_active=True,
|
project__project_projectmember__is_active=True,
|
||||||
project__archived_at__isnull=True,
|
project__archived_at__isnull=True,
|
||||||
)
|
)
|
||||||
.filter(~Q(name="Triage"))
|
.filter(is_triage=False)
|
||||||
.select_related("project")
|
.select_related("project")
|
||||||
.select_related("workspace")
|
.select_related("workspace")
|
||||||
.distinct()
|
.distinct()
|
||||||
@ -76,7 +76,7 @@ class StateViewSet(BaseViewSet):
|
|||||||
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
|
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
|
||||||
def destroy(self, request, slug, project_id, pk):
|
def destroy(self, request, slug, project_id, pk):
|
||||||
state = State.objects.get(
|
state = State.objects.get(
|
||||||
~Q(name="Triage"),
|
is_triage=False,
|
||||||
pk=pk,
|
pk=pk,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
|
@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 4.2.10 on 2024-04-02 12:18
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def update_project_state_group(apps, schema_editor):
|
||||||
|
State = apps.get_model("db", "State")
|
||||||
|
|
||||||
|
# Update states in bulk
|
||||||
|
State.objects.filter(group="backlog", name="Triage").update(
|
||||||
|
is_triage=True, group="triage"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("db", "0062_cycle_archived_at_module_archived_at_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="state",
|
||||||
|
name="is_triage",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="state",
|
||||||
|
name="group",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("backlog", "Backlog"),
|
||||||
|
("unstarted", "Unstarted"),
|
||||||
|
("started", "Started"),
|
||||||
|
("completed", "Completed"),
|
||||||
|
("cancelled", "Cancelled"),
|
||||||
|
("triage", "Triage"),
|
||||||
|
],
|
||||||
|
default="backlog",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(update_project_state_group),
|
||||||
|
]
|
@ -171,14 +171,14 @@ class Issue(ProjectBaseModel):
|
|||||||
from plane.db.models import State
|
from plane.db.models import State
|
||||||
|
|
||||||
default_state = State.objects.filter(
|
default_state = State.objects.filter(
|
||||||
~models.Q(name="Triage"),
|
~models.Q(is_triage=True),
|
||||||
project=self.project,
|
project=self.project,
|
||||||
default=True,
|
default=True,
|
||||||
).first()
|
).first()
|
||||||
# if there is no default state assign any random state
|
# if there is no default state assign any random state
|
||||||
if default_state is None:
|
if default_state is None:
|
||||||
random_state = State.objects.filter(
|
random_state = State.objects.filter(
|
||||||
~models.Q(name="Triage"), project=self.project
|
~models.Q(is_triage=True), project=self.project
|
||||||
).first()
|
).first()
|
||||||
self.state = random_state
|
self.state = random_state
|
||||||
else:
|
else:
|
||||||
|
@ -21,10 +21,12 @@ class State(ProjectBaseModel):
|
|||||||
("started", "Started"),
|
("started", "Started"),
|
||||||
("completed", "Completed"),
|
("completed", "Completed"),
|
||||||
("cancelled", "Cancelled"),
|
("cancelled", "Cancelled"),
|
||||||
|
("triage", "Triage")
|
||||||
),
|
),
|
||||||
default="backlog",
|
default="backlog",
|
||||||
max_length=20,
|
max_length=20,
|
||||||
)
|
)
|
||||||
|
is_triage = models.BooleanField(default=False)
|
||||||
default = models.BooleanField(default=False)
|
default = models.BooleanField(default=False)
|
||||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
@ -83,25 +83,25 @@ def date_filter(filter, date_term, queries):
|
|||||||
filter[f"{date_term}__lte"] = date_query[0]
|
filter[f"{date_term}__lte"] = date_query[0]
|
||||||
|
|
||||||
|
|
||||||
def filter_state(params, filter, method):
|
def filter_state(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
states = [
|
states = [
|
||||||
item for item in params.get("state").split(",") if item != "null"
|
item for item in params.get("state").split(",") if item != "null"
|
||||||
]
|
]
|
||||||
states = filter_valid_uuids(states)
|
states = filter_valid_uuids(states)
|
||||||
if len(states) and "" not in states:
|
if len(states) and "" not in states:
|
||||||
filter["state__in"] = states
|
filter[f"{prefix}state__in"] = states
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
params.get("state", None)
|
params.get("state", None)
|
||||||
and len(params.get("state"))
|
and len(params.get("state"))
|
||||||
and params.get("state") != "null"
|
and params.get("state") != "null"
|
||||||
):
|
):
|
||||||
filter["state__in"] = params.get("state")
|
filter[f"{prefix}state__in"] = params.get("state")
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_state_group(params, filter, method):
|
def filter_state_group(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
state_group = [
|
state_group = [
|
||||||
item
|
item
|
||||||
@ -109,18 +109,18 @@ def filter_state_group(params, filter, method):
|
|||||||
if item != "null"
|
if item != "null"
|
||||||
]
|
]
|
||||||
if len(state_group) and "" not in state_group:
|
if len(state_group) and "" not in state_group:
|
||||||
filter["state__group__in"] = state_group
|
filter[f"{prefix}state__group__in"] = state_group
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
params.get("state_group", None)
|
params.get("state_group", None)
|
||||||
and len(params.get("state_group"))
|
and len(params.get("state_group"))
|
||||||
and params.get("state_group") != "null"
|
and params.get("state_group") != "null"
|
||||||
):
|
):
|
||||||
filter["state__group__in"] = params.get("state_group")
|
filter[f"{prefix}state__group__in"] = params.get("state_group")
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_estimate_point(params, filter, method):
|
def filter_estimate_point(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
estimate_points = [
|
estimate_points = [
|
||||||
item
|
item
|
||||||
@ -128,18 +128,20 @@ def filter_estimate_point(params, filter, method):
|
|||||||
if item != "null"
|
if item != "null"
|
||||||
]
|
]
|
||||||
if len(estimate_points) and "" not in estimate_points:
|
if len(estimate_points) and "" not in estimate_points:
|
||||||
filter["estimate_point__in"] = estimate_points
|
filter[f"{prefix}estimate_point__in"] = estimate_points
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
params.get("estimate_point", None)
|
params.get("estimate_point", None)
|
||||||
and len(params.get("estimate_point"))
|
and len(params.get("estimate_point"))
|
||||||
and params.get("estimate_point") != "null"
|
and params.get("estimate_point") != "null"
|
||||||
):
|
):
|
||||||
filter["estimate_point__in"] = params.get("estimate_point")
|
filter[f"{prefix}estimate_point__in"] = params.get(
|
||||||
|
"estimate_point"
|
||||||
|
)
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_priority(params, filter, method):
|
def filter_priority(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
priorities = [
|
priorities = [
|
||||||
item
|
item
|
||||||
@ -147,47 +149,47 @@ def filter_priority(params, filter, method):
|
|||||||
if item != "null"
|
if item != "null"
|
||||||
]
|
]
|
||||||
if len(priorities) and "" not in priorities:
|
if len(priorities) and "" not in priorities:
|
||||||
filter["priority__in"] = priorities
|
filter[f"{prefix}priority__in"] = priorities
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_parent(params, filter, method):
|
def filter_parent(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
parents = [
|
parents = [
|
||||||
item for item in params.get("parent").split(",") if item != "null"
|
item for item in params.get("parent").split(",") if item != "null"
|
||||||
]
|
]
|
||||||
parents = filter_valid_uuids(parents)
|
parents = filter_valid_uuids(parents)
|
||||||
if len(parents) and "" not in parents:
|
if len(parents) and "" not in parents:
|
||||||
filter["parent__in"] = parents
|
filter[f"{prefix}parent__in"] = parents
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
params.get("parent", None)
|
params.get("parent", None)
|
||||||
and len(params.get("parent"))
|
and len(params.get("parent"))
|
||||||
and params.get("parent") != "null"
|
and params.get("parent") != "null"
|
||||||
):
|
):
|
||||||
filter["parent__in"] = params.get("parent")
|
filter[f"{prefix}parent__in"] = params.get("parent")
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_labels(params, filter, method):
|
def filter_labels(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
labels = [
|
labels = [
|
||||||
item for item in params.get("labels").split(",") if item != "null"
|
item for item in params.get("labels").split(",") if item != "null"
|
||||||
]
|
]
|
||||||
labels = filter_valid_uuids(labels)
|
labels = filter_valid_uuids(labels)
|
||||||
if len(labels) and "" not in labels:
|
if len(labels) and "" not in labels:
|
||||||
filter["labels__in"] = labels
|
filter[f"{prefix}labels__in"] = labels
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
params.get("labels", None)
|
params.get("labels", None)
|
||||||
and len(params.get("labels"))
|
and len(params.get("labels"))
|
||||||
and params.get("labels") != "null"
|
and params.get("labels") != "null"
|
||||||
):
|
):
|
||||||
filter["labels__in"] = params.get("labels")
|
filter[f"{prefix}labels__in"] = params.get("labels")
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_assignees(params, filter, method):
|
def filter_assignees(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
assignees = [
|
assignees = [
|
||||||
item
|
item
|
||||||
@ -196,18 +198,18 @@ def filter_assignees(params, filter, method):
|
|||||||
]
|
]
|
||||||
assignees = filter_valid_uuids(assignees)
|
assignees = filter_valid_uuids(assignees)
|
||||||
if len(assignees) and "" not in assignees:
|
if len(assignees) and "" not in assignees:
|
||||||
filter["assignees__in"] = assignees
|
filter[f"{prefix}assignees__in"] = assignees
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
params.get("assignees", None)
|
params.get("assignees", None)
|
||||||
and len(params.get("assignees"))
|
and len(params.get("assignees"))
|
||||||
and params.get("assignees") != "null"
|
and params.get("assignees") != "null"
|
||||||
):
|
):
|
||||||
filter["assignees__in"] = params.get("assignees")
|
filter[f"{prefix}assignees__in"] = params.get("assignees")
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_mentions(params, filter, method):
|
def filter_mentions(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
mentions = [
|
mentions = [
|
||||||
item
|
item
|
||||||
@ -216,18 +218,20 @@ def filter_mentions(params, filter, method):
|
|||||||
]
|
]
|
||||||
mentions = filter_valid_uuids(mentions)
|
mentions = filter_valid_uuids(mentions)
|
||||||
if len(mentions) and "" not in mentions:
|
if len(mentions) and "" not in mentions:
|
||||||
filter["issue_mention__mention__id__in"] = mentions
|
filter[f"{prefix}issue_mention__mention__id__in"] = mentions
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
params.get("mentions", None)
|
params.get("mentions", None)
|
||||||
and len(params.get("mentions"))
|
and len(params.get("mentions"))
|
||||||
and params.get("mentions") != "null"
|
and params.get("mentions") != "null"
|
||||||
):
|
):
|
||||||
filter["issue_mention__mention__id__in"] = params.get("mentions")
|
filter[f"{prefix}issue_mention__mention__id__in"] = params.get(
|
||||||
|
"mentions"
|
||||||
|
)
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_created_by(params, filter, method):
|
def filter_created_by(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
created_bys = [
|
created_bys = [
|
||||||
item
|
item
|
||||||
@ -236,94 +240,98 @@ def filter_created_by(params, filter, method):
|
|||||||
]
|
]
|
||||||
created_bys = filter_valid_uuids(created_bys)
|
created_bys = filter_valid_uuids(created_bys)
|
||||||
if len(created_bys) and "" not in created_bys:
|
if len(created_bys) and "" not in created_bys:
|
||||||
filter["created_by__in"] = created_bys
|
filter[f"{prefix}created_by__in"] = created_bys
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
params.get("created_by", None)
|
params.get("created_by", None)
|
||||||
and len(params.get("created_by"))
|
and len(params.get("created_by"))
|
||||||
and params.get("created_by") != "null"
|
and params.get("created_by") != "null"
|
||||||
):
|
):
|
||||||
filter["created_by__in"] = params.get("created_by")
|
filter[f"{prefix}created_by__in"] = params.get("created_by")
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_name(params, filter, method):
|
def filter_name(params, filter, method, prefix=""):
|
||||||
if params.get("name", "") != "":
|
if params.get("name", "") != "":
|
||||||
filter["name__icontains"] = params.get("name")
|
filter[f"{prefix}name__icontains"] = params.get("name")
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_created_at(params, filter, method):
|
def filter_created_at(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
created_ats = params.get("created_at").split(",")
|
created_ats = params.get("created_at").split(",")
|
||||||
if len(created_ats) and "" not in created_ats:
|
if len(created_ats) and "" not in created_ats:
|
||||||
date_filter(
|
date_filter(
|
||||||
filter=filter,
|
filter=filter,
|
||||||
date_term="created_at__date",
|
date_term=f"{prefix}created_at__date",
|
||||||
queries=created_ats,
|
queries=created_ats,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if params.get("created_at", None) and len(params.get("created_at")):
|
if params.get("created_at", None) and len(params.get("created_at")):
|
||||||
date_filter(
|
date_filter(
|
||||||
filter=filter,
|
filter=filter,
|
||||||
date_term="created_at__date",
|
date_term=f"{prefix}created_at__date",
|
||||||
queries=params.get("created_at", []),
|
queries=params.get("created_at", []),
|
||||||
)
|
)
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_updated_at(params, filter, method):
|
def filter_updated_at(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
updated_ats = params.get("updated_at").split(",")
|
updated_ats = params.get("updated_at").split(",")
|
||||||
if len(updated_ats) and "" not in updated_ats:
|
if len(updated_ats) and "" not in updated_ats:
|
||||||
date_filter(
|
date_filter(
|
||||||
filter=filter,
|
filter=filter,
|
||||||
date_term="created_at__date",
|
date_term=f"{prefix}created_at__date",
|
||||||
queries=updated_ats,
|
queries=updated_ats,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if params.get("updated_at", None) and len(params.get("updated_at")):
|
if params.get("updated_at", None) and len(params.get("updated_at")):
|
||||||
date_filter(
|
date_filter(
|
||||||
filter=filter,
|
filter=filter,
|
||||||
date_term="created_at__date",
|
date_term=f"{prefix}created_at__date",
|
||||||
queries=params.get("updated_at", []),
|
queries=params.get("updated_at", []),
|
||||||
)
|
)
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_start_date(params, filter, method):
|
def filter_start_date(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
start_dates = params.get("start_date").split(",")
|
start_dates = params.get("start_date").split(",")
|
||||||
if len(start_dates) and "" not in start_dates:
|
if len(start_dates) and "" not in start_dates:
|
||||||
date_filter(
|
date_filter(
|
||||||
filter=filter, date_term="start_date", queries=start_dates
|
filter=filter,
|
||||||
|
date_term=f"{prefix}start_date",
|
||||||
|
queries=start_dates,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if params.get("start_date", None) and len(params.get("start_date")):
|
if params.get("start_date", None) and len(params.get("start_date")):
|
||||||
filter["start_date"] = params.get("start_date")
|
filter[f"{prefix}start_date"] = params.get("start_date")
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_target_date(params, filter, method):
|
def filter_target_date(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
target_dates = params.get("target_date").split(",")
|
target_dates = params.get("target_date").split(",")
|
||||||
if len(target_dates) and "" not in target_dates:
|
if len(target_dates) and "" not in target_dates:
|
||||||
date_filter(
|
date_filter(
|
||||||
filter=filter, date_term="target_date", queries=target_dates
|
filter=filter,
|
||||||
|
date_term=f"{prefix}target_date",
|
||||||
|
queries=target_dates,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if params.get("target_date", None) and len(params.get("target_date")):
|
if params.get("target_date", None) and len(params.get("target_date")):
|
||||||
filter["target_date"] = params.get("target_date")
|
filter[f"{prefix}target_date"] = params.get("target_date")
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_completed_at(params, filter, method):
|
def filter_completed_at(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
completed_ats = params.get("completed_at").split(",")
|
completed_ats = params.get("completed_at").split(",")
|
||||||
if len(completed_ats) and "" not in completed_ats:
|
if len(completed_ats) and "" not in completed_ats:
|
||||||
date_filter(
|
date_filter(
|
||||||
filter=filter,
|
filter=filter,
|
||||||
date_term="completed_at__date",
|
date_term=f"{prefix}completed_at__date",
|
||||||
queries=completed_ats,
|
queries=completed_ats,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -332,13 +340,13 @@ def filter_completed_at(params, filter, method):
|
|||||||
):
|
):
|
||||||
date_filter(
|
date_filter(
|
||||||
filter=filter,
|
filter=filter,
|
||||||
date_term="completed_at__date",
|
date_term=f"{prefix}completed_at__date",
|
||||||
queries=params.get("completed_at", []),
|
queries=params.get("completed_at", []),
|
||||||
)
|
)
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_issue_state_type(params, filter, method):
|
def filter_issue_state_type(params, filter, method, prefix=""):
|
||||||
type = params.get("type", "all")
|
type = params.get("type", "all")
|
||||||
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||||
if type == "backlog":
|
if type == "backlog":
|
||||||
@ -346,65 +354,67 @@ def filter_issue_state_type(params, filter, method):
|
|||||||
if type == "active":
|
if type == "active":
|
||||||
group = ["unstarted", "started"]
|
group = ["unstarted", "started"]
|
||||||
|
|
||||||
filter["state__group__in"] = group
|
filter[f"{prefix}state__group__in"] = group
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_project(params, filter, method):
|
def filter_project(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
projects = [
|
projects = [
|
||||||
item for item in params.get("project").split(",") if item != "null"
|
item for item in params.get("project").split(",") if item != "null"
|
||||||
]
|
]
|
||||||
projects = filter_valid_uuids(projects)
|
projects = filter_valid_uuids(projects)
|
||||||
if len(projects) and "" not in projects:
|
if len(projects) and "" not in projects:
|
||||||
filter["project__in"] = projects
|
filter[f"{prefix}project__in"] = projects
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
params.get("project", None)
|
params.get("project", None)
|
||||||
and len(params.get("project"))
|
and len(params.get("project"))
|
||||||
and params.get("project") != "null"
|
and params.get("project") != "null"
|
||||||
):
|
):
|
||||||
filter["project__in"] = params.get("project")
|
filter[f"{prefix}project__in"] = params.get("project")
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_cycle(params, filter, method):
|
def filter_cycle(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
cycles = [
|
cycles = [
|
||||||
item for item in params.get("cycle").split(",") if item != "null"
|
item for item in params.get("cycle").split(",") if item != "null"
|
||||||
]
|
]
|
||||||
cycles = filter_valid_uuids(cycles)
|
cycles = filter_valid_uuids(cycles)
|
||||||
if len(cycles) and "" not in cycles:
|
if len(cycles) and "" not in cycles:
|
||||||
filter["issue_cycle__cycle_id__in"] = cycles
|
filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
params.get("cycle", None)
|
params.get("cycle", None)
|
||||||
and len(params.get("cycle"))
|
and len(params.get("cycle"))
|
||||||
and params.get("cycle") != "null"
|
and params.get("cycle") != "null"
|
||||||
):
|
):
|
||||||
filter["issue_cycle__cycle_id__in"] = params.get("cycle")
|
filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle")
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_module(params, filter, method):
|
def filter_module(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
modules = [
|
modules = [
|
||||||
item for item in params.get("module").split(",") if item != "null"
|
item for item in params.get("module").split(",") if item != "null"
|
||||||
]
|
]
|
||||||
modules = filter_valid_uuids(modules)
|
modules = filter_valid_uuids(modules)
|
||||||
if len(modules) and "" not in modules:
|
if len(modules) and "" not in modules:
|
||||||
filter["issue_module__module_id__in"] = modules
|
filter[f"{prefix}issue_module__module_id__in"] = modules
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
params.get("module", None)
|
params.get("module", None)
|
||||||
and len(params.get("module"))
|
and len(params.get("module"))
|
||||||
and params.get("module") != "null"
|
and params.get("module") != "null"
|
||||||
):
|
):
|
||||||
filter["issue_module__module_id__in"] = params.get("module")
|
filter[f"{prefix}issue_module__module_id__in"] = params.get(
|
||||||
|
"module"
|
||||||
|
)
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_inbox_status(params, filter, method):
|
def filter_inbox_status(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
status = [
|
status = [
|
||||||
item
|
item
|
||||||
@ -412,30 +422,32 @@ def filter_inbox_status(params, filter, method):
|
|||||||
if item != "null"
|
if item != "null"
|
||||||
]
|
]
|
||||||
if len(status) and "" not in status:
|
if len(status) and "" not in status:
|
||||||
filter["issue_inbox__status__in"] = status
|
filter[f"{prefix}issue_inbox__status__in"] = status
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
params.get("inbox_status", None)
|
params.get("inbox_status", None)
|
||||||
and len(params.get("inbox_status"))
|
and len(params.get("inbox_status"))
|
||||||
and params.get("inbox_status") != "null"
|
and params.get("inbox_status") != "null"
|
||||||
):
|
):
|
||||||
filter["issue_inbox__status__in"] = params.get("inbox_status")
|
filter[f"{prefix}issue_inbox__status__in"] = params.get(
|
||||||
|
"inbox_status"
|
||||||
|
)
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_sub_issue_toggle(params, filter, method):
|
def filter_sub_issue_toggle(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
sub_issue = params.get("sub_issue", "false")
|
sub_issue = params.get("sub_issue", "false")
|
||||||
if sub_issue == "false":
|
if sub_issue == "false":
|
||||||
filter["parent__isnull"] = True
|
filter[f"{prefix}parent__isnull"] = True
|
||||||
else:
|
else:
|
||||||
sub_issue = params.get("sub_issue", "false")
|
sub_issue = params.get("sub_issue", "false")
|
||||||
if sub_issue == "false":
|
if sub_issue == "false":
|
||||||
filter["parent__isnull"] = True
|
filter[f"{prefix}parent__isnull"] = True
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_subscribed_issues(params, filter, method):
|
def filter_subscribed_issues(params, filter, method, prefix=""):
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
subscribers = [
|
subscribers = [
|
||||||
item
|
item
|
||||||
@ -444,28 +456,30 @@ def filter_subscribed_issues(params, filter, method):
|
|||||||
]
|
]
|
||||||
subscribers = filter_valid_uuids(subscribers)
|
subscribers = filter_valid_uuids(subscribers)
|
||||||
if len(subscribers) and "" not in subscribers:
|
if len(subscribers) and "" not in subscribers:
|
||||||
filter["issue_subscribers__subscriber_id__in"] = subscribers
|
filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
|
||||||
|
subscribers
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
params.get("subscriber", None)
|
params.get("subscriber", None)
|
||||||
and len(params.get("subscriber"))
|
and len(params.get("subscriber"))
|
||||||
and params.get("subscriber") != "null"
|
and params.get("subscriber") != "null"
|
||||||
):
|
):
|
||||||
filter["issue_subscribers__subscriber_id__in"] = params.get(
|
filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
|
||||||
"subscriber"
|
params.get("subscriber")
|
||||||
)
|
)
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def filter_start_target_date_issues(params, filter, method):
|
def filter_start_target_date_issues(params, filter, method, prefix=""):
|
||||||
start_target_date = params.get("start_target_date", "false")
|
start_target_date = params.get("start_target_date", "false")
|
||||||
if start_target_date == "true":
|
if start_target_date == "true":
|
||||||
filter["target_date__isnull"] = False
|
filter[f"{prefix}target_date__isnull"] = False
|
||||||
filter["start_date__isnull"] = False
|
filter[f"{prefix}start_date__isnull"] = False
|
||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def issue_filters(query_params, method):
|
def issue_filters(query_params, method, prefix=""):
|
||||||
filter = {}
|
filter = {}
|
||||||
|
|
||||||
ISSUE_FILTER = {
|
ISSUE_FILTER = {
|
||||||
@ -497,6 +511,5 @@ def issue_filters(query_params, method):
|
|||||||
for key, value in ISSUE_FILTER.items():
|
for key, value in ISSUE_FILTER.items():
|
||||||
if key in query_params:
|
if key in query_params:
|
||||||
func = value
|
func = value
|
||||||
func(query_params, filter, method)
|
func(query_params, filter, method, prefix)
|
||||||
|
|
||||||
return filter
|
return filter
|
||||||
|
@ -134,7 +134,7 @@ class OffsetPaginator:
|
|||||||
results=results,
|
results=results,
|
||||||
next=next_cursor,
|
next=next_cursor,
|
||||||
prev=prev_cursor,
|
prev=prev_cursor,
|
||||||
hits=None,
|
hits=count,
|
||||||
max_hits=max_hits,
|
max_hits=max_hits,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -217,6 +217,7 @@ class BasePaginator:
|
|||||||
"prev_page_results": cursor_result.prev.has_results,
|
"prev_page_results": cursor_result.prev.has_results,
|
||||||
"count": cursor_result.__len__(),
|
"count": cursor_result.__len__(),
|
||||||
"total_pages": cursor_result.max_hits,
|
"total_pages": cursor_result.max_hits,
|
||||||
|
"total_results": cursor_result.hits,
|
||||||
"extra_stats": extra_stats,
|
"extra_stats": extra_stats,
|
||||||
"results": results,
|
"results": results,
|
||||||
}
|
}
|
||||||
|
11
packages/types/src/common.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export type TPaginationInfo = {
|
||||||
|
count: number;
|
||||||
|
extra_stats: string | null;
|
||||||
|
next_cursor: string;
|
||||||
|
next_page_results: boolean;
|
||||||
|
prev_cursor: string;
|
||||||
|
prev_page_results: boolean;
|
||||||
|
total_pages: number;
|
||||||
|
per_page?: number;
|
||||||
|
total_results: number;
|
||||||
|
};
|
76
packages/types/src/inbox.d.ts
vendored
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { TPaginationInfo } from "./common";
|
||||||
|
import { TIssuePriorities } from "./issues";
|
||||||
|
import { TIssue } from "./issues/base";
|
||||||
|
|
||||||
|
export type TInboxIssueCurrentTab = "open" | "closed";
|
||||||
|
|
||||||
|
export type TInboxIssueStatus = -2 | -1 | 0 | 1 | 2;
|
||||||
|
|
||||||
|
// filters
|
||||||
|
export type TInboxIssueFilterMemberKeys = "assignee" | "created_by";
|
||||||
|
|
||||||
|
export type TInboxIssueFilterDateKeys = "created_at" | "updated_at";
|
||||||
|
|
||||||
|
export type TInboxIssueFilter = {
|
||||||
|
[key in TInboxIssueFilterMemberKeys]: string[] | undefined;
|
||||||
|
} & {
|
||||||
|
[key in TInboxIssueFilterDateKeys]: string[] | undefined;
|
||||||
|
} & {
|
||||||
|
status: TInboxIssueStatus[] | undefined;
|
||||||
|
priority: TIssuePriorities[] | undefined;
|
||||||
|
label: string[] | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// sorting filters
|
||||||
|
export type TInboxIssueSortingKeys = "order_by" | "sort_by";
|
||||||
|
|
||||||
|
export type TInboxIssueSortingOrderByKeys =
|
||||||
|
| "issue__created_at"
|
||||||
|
| "issue__updated_at"
|
||||||
|
| "issue__sequence_id";
|
||||||
|
|
||||||
|
export type TInboxIssueSortingSortByKeys = "asc" | "desc";
|
||||||
|
|
||||||
|
export type TInboxIssueSorting = {
|
||||||
|
order_by: TInboxIssueSortingOrderByKeys | undefined;
|
||||||
|
sort_by: TInboxIssueSortingSortByKeys | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// filtering and sorting types for query params
|
||||||
|
export type TInboxIssueSortingOrderByQueryParamKeys =
|
||||||
|
| "issue__created_at"
|
||||||
|
| "-issue__created_at"
|
||||||
|
| "issue__updated_at"
|
||||||
|
| "-issue__updated_at"
|
||||||
|
| "issue__sequence_id"
|
||||||
|
| "-issue__sequence_id";
|
||||||
|
|
||||||
|
export type TInboxIssueSortingOrderByQueryParam = {
|
||||||
|
order_by: TInboxIssueSortingOrderByQueryParamKeys;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TInboxIssuesQueryParams = {
|
||||||
|
[key in TInboxIssueFilter]: string;
|
||||||
|
} & TInboxIssueSortingOrderByQueryParam & {
|
||||||
|
per_page: number;
|
||||||
|
cursor: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// inbox issue types
|
||||||
|
export type TInboxIssue = {
|
||||||
|
id: string;
|
||||||
|
status: TInboxIssueStatus;
|
||||||
|
snoozed_till: Date | null;
|
||||||
|
duplicate_to: string | null;
|
||||||
|
source: string;
|
||||||
|
issue: TIssue;
|
||||||
|
created_by: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TInboxIssuePaginationInfo = TPaginationInfo & {
|
||||||
|
total_results: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TInboxIssueWithPagination = TInboxIssuePaginationInfo & {
|
||||||
|
results: TInboxIssue[];
|
||||||
|
};
|
65
packages/types/src/inbox/inbox-issue.d.ts
vendored
@ -1,65 +0,0 @@
|
|||||||
import { TIssue } from "../issues/base";
|
|
||||||
|
|
||||||
export enum EInboxStatus {
|
|
||||||
PENDING = -2,
|
|
||||||
REJECT = -1,
|
|
||||||
SNOOZED = 0,
|
|
||||||
ACCEPTED = 1,
|
|
||||||
DUPLICATE = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TInboxStatus =
|
|
||||||
| EInboxStatus.PENDING
|
|
||||||
| EInboxStatus.REJECT
|
|
||||||
| EInboxStatus.SNOOZED
|
|
||||||
| EInboxStatus.ACCEPTED
|
|
||||||
| EInboxStatus.DUPLICATE;
|
|
||||||
|
|
||||||
export type TInboxIssueDetail = {
|
|
||||||
id?: string;
|
|
||||||
source: "in-app";
|
|
||||||
status: TInboxStatus;
|
|
||||||
duplicate_to: string | undefined;
|
|
||||||
snoozed_till: Date | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TInboxIssueDetailMap = Record<
|
|
||||||
string,
|
|
||||||
Record<string, TInboxIssueDetail>
|
|
||||||
>; // inbox_id -> issue_id -> TInboxIssueDetail
|
|
||||||
|
|
||||||
export type TInboxIssueDetailIdMap = Record<string, string[]>; // inbox_id -> issue_id[]
|
|
||||||
|
|
||||||
export type TInboxIssueExtendedDetail = TIssue & {
|
|
||||||
issue_inbox: TInboxIssueDetail[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// property type checks
|
|
||||||
export type TInboxPendingStatus = {
|
|
||||||
status: EInboxStatus.PENDING;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TInboxRejectStatus = {
|
|
||||||
status: EInboxStatus.REJECT;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TInboxSnoozedStatus = {
|
|
||||||
status: EInboxStatus.SNOOZED;
|
|
||||||
snoozed_till: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TInboxAcceptedStatus = {
|
|
||||||
status: EInboxStatus.ACCEPTED;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TInboxDuplicateStatus = {
|
|
||||||
status: EInboxStatus.DUPLICATE;
|
|
||||||
duplicate_to: string; // issue_id
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TInboxDetailedStatus =
|
|
||||||
| TInboxPendingStatus
|
|
||||||
| TInboxRejectStatus
|
|
||||||
| TInboxSnoozedStatus
|
|
||||||
| TInboxAcceptedStatus
|
|
||||||
| TInboxDuplicateStatus;
|
|
44
packages/types/src/inbox/inbox-types.d.ts
vendored
@ -1,44 +0,0 @@
|
|||||||
import { TIssue } from "../issues/base";
|
|
||||||
import type { IProjectLite } from "../project";
|
|
||||||
|
|
||||||
export type TInboxIssueExtended = {
|
|
||||||
completed_at: string | null;
|
|
||||||
start_date: string | null;
|
|
||||||
target_date: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IInboxIssue extends TIssue, TInboxIssueExtended {
|
|
||||||
issue_inbox: {
|
|
||||||
duplicate_to: string | null;
|
|
||||||
id: string;
|
|
||||||
snoozed_till: Date | null;
|
|
||||||
source: string;
|
|
||||||
status: -2 | -1 | 0 | 1 | 2;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IInboxFilterOptions {
|
|
||||||
priority?: string[] | null;
|
|
||||||
inbox_status?: number[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IInboxQueryParams {
|
|
||||||
priority: string | null;
|
|
||||||
inbox_status: string | null;
|
|
||||||
}
|
|
27
packages/types/src/inbox/inbox.d.ts
vendored
@ -1,27 +0,0 @@
|
|||||||
export type TInboxIssueFilterOptions = {
|
|
||||||
priority: string[];
|
|
||||||
inbox_status: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TInboxIssueQueryParams = "priority" | "inbox_status";
|
|
||||||
|
|
||||||
export type TInboxIssueFilters = { filters: TInboxIssueFilterOptions };
|
|
||||||
|
|
||||||
export type TInbox = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
workspace: string;
|
|
||||||
project: string;
|
|
||||||
is_default: boolean;
|
|
||||||
view_props: TInboxIssueFilters;
|
|
||||||
created_by: string;
|
|
||||||
updated_by: string;
|
|
||||||
created_at: Date;
|
|
||||||
updated_at: Date;
|
|
||||||
pending_issue_count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TInboxDetailMap = Record<string, TInbox>; // inbox_id -> TInbox
|
|
||||||
|
|
||||||
export type TInboxDetailIdMap = Record<string, string[]>; // project_id -> inbox_id[]
|
|
3
packages/types/src/inbox/root.d.ts
vendored
@ -1,3 +0,0 @@
|
|||||||
export * from "./inbox-issue";
|
|
||||||
export * from "./inbox-types";
|
|
||||||
export * from "./inbox";
|
|
6
packages/types/src/index.d.ts
vendored
@ -12,10 +12,7 @@ export * from "./pages";
|
|||||||
export * from "./ai";
|
export * from "./ai";
|
||||||
export * from "./estimate";
|
export * from "./estimate";
|
||||||
export * from "./importer";
|
export * from "./importer";
|
||||||
|
export * from "./inbox";
|
||||||
// FIXME: Remove this after development and the refactor/mobx-store-issue branch is stable
|
|
||||||
export * from "./inbox/root";
|
|
||||||
|
|
||||||
export * from "./analytics";
|
export * from "./analytics";
|
||||||
export * from "./calendar";
|
export * from "./calendar";
|
||||||
export * from "./notifications";
|
export * from "./notifications";
|
||||||
@ -29,3 +26,4 @@ export * from "./auth";
|
|||||||
export * from "./api_token";
|
export * from "./api_token";
|
||||||
export * from "./instance";
|
export * from "./instance";
|
||||||
export * from "./app";
|
export * from "./app";
|
||||||
|
export * from "./common";
|
||||||
|
@ -1,16 +1,15 @@
|
|||||||
import { FC, useState } from "react";
|
import { FC, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, RefreshCcw } from "lucide-react";
|
||||||
// hooks
|
|
||||||
// ui
|
// ui
|
||||||
import { Breadcrumbs, Button, LayersIcon } from "@plane/ui";
|
import { Breadcrumbs, Button, LayersIcon } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { BreadcrumbLink } from "@/components/common";
|
import { BreadcrumbLink } from "@/components/common";
|
||||||
import { CreateInboxIssueModal } from "@/components/inbox";
|
import { CreateInboxIssueModal } from "@/components/inbox";
|
||||||
// helper
|
|
||||||
import { ProjectLogo } from "@/components/project";
|
import { ProjectLogo } from "@/components/project";
|
||||||
import { useProject } from "@/hooks/store";
|
// hooks
|
||||||
|
import { useProject, useProjectInbox } from "@/hooks/store";
|
||||||
|
|
||||||
export const ProjectInboxHeader: FC = observer(() => {
|
export const ProjectInboxHeader: FC = observer(() => {
|
||||||
// states
|
// states
|
||||||
@ -20,11 +19,12 @@ export const ProjectInboxHeader: FC = observer(() => {
|
|||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
// store hooks
|
// store hooks
|
||||||
const { currentProjectDetails } = useProject();
|
const { currentProjectDetails } = useProject();
|
||||||
|
const { isLoading } = useProjectInbox();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
|
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
|
||||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||||
<div>
|
<div className="flex items-center gap-4">
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<Breadcrumbs.BreadcrumbItem
|
<Breadcrumbs.BreadcrumbItem
|
||||||
type="text"
|
type="text"
|
||||||
@ -50,6 +50,13 @@ export const ProjectInboxHeader: FC = observer(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
|
|
||||||
|
{isLoading === "pagination-loading" && (
|
||||||
|
<div className="flex items-center gap-1.5 text-custom-text-300">
|
||||||
|
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
<p className="text-sm">Syncing...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
276
web/components/inbox/content/inbox-issue-header.tsx
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
import { FC, useCallback, useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { ChevronDown, ChevronUp, Clock, ExternalLink, FileStack, Link, Trash2 } from "lucide-react";
|
||||||
|
import { Button, ControlLink, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
AcceptIssueModal,
|
||||||
|
DeclineIssueModal,
|
||||||
|
DeleteInboxIssueModal,
|
||||||
|
InboxIssueSnoozeModal,
|
||||||
|
InboxIssueStatus,
|
||||||
|
SelectDuplicateInboxIssueModal,
|
||||||
|
} from "@/components/inbox";
|
||||||
|
import { IssueUpdateStatus } from "@/components/issues";
|
||||||
|
// constants
|
||||||
|
import { EUserProjectRoles } from "@/constants/project";
|
||||||
|
// helpers
|
||||||
|
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||||
|
// hooks
|
||||||
|
import { useUser, useProjectInbox, useProject } from "@/hooks/store";
|
||||||
|
// store types
|
||||||
|
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||||
|
|
||||||
|
type TInboxIssueActionsHeader = {
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
inboxIssue: IInboxIssueStore | undefined;
|
||||||
|
isSubmitting: "submitting" | "submitted" | "saved";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((props) => {
|
||||||
|
const { workspaceSlug, projectId, inboxIssue, isSubmitting } = props;
|
||||||
|
// states
|
||||||
|
const [isSnoozeDateModalOpen, setIsSnoozeDateModalOpen] = useState(false);
|
||||||
|
const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false);
|
||||||
|
const [acceptIssueModal, setAcceptIssueModal] = useState(false);
|
||||||
|
const [declineIssueModal, setDeclineIssueModal] = useState(false);
|
||||||
|
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||||
|
// store
|
||||||
|
const { deleteInboxIssue, inboxIssuesArray } = useProjectInbox();
|
||||||
|
const {
|
||||||
|
currentUser,
|
||||||
|
membership: { currentProjectRole },
|
||||||
|
} = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const { getProjectById } = useProject();
|
||||||
|
|
||||||
|
const issue = inboxIssue?.issue;
|
||||||
|
// derived values
|
||||||
|
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||||
|
const canMarkAsDuplicate = isAllowed && inboxIssue?.status === -2;
|
||||||
|
const canMarkAsAccepted = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2);
|
||||||
|
const canMarkAsDeclined = isAllowed && inboxIssue?.status === -2;
|
||||||
|
const canDelete = isAllowed || inboxIssue?.created_by === currentUser?.id;
|
||||||
|
const isCompleted = inboxIssue?.status === 1;
|
||||||
|
|
||||||
|
const currentInboxIssueId = inboxIssue?.issue?.id;
|
||||||
|
|
||||||
|
const issueLink = `${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`;
|
||||||
|
|
||||||
|
const handleInboxIssueAccept = async () => {
|
||||||
|
inboxIssue?.updateInboxIssueStatus(1);
|
||||||
|
setAcceptIssueModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInboxIssueDecline = async () => {
|
||||||
|
inboxIssue?.updateInboxIssueStatus(-1);
|
||||||
|
setDeclineIssueModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInboxIssueDuplicate = (issueId: string) => {
|
||||||
|
inboxIssue?.updateInboxIssueDuplicateTo(issueId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInboxSIssueSnooze = async (date: Date) => {
|
||||||
|
inboxIssue?.updateInboxIssueSnoozeTill(date);
|
||||||
|
setIsSnoozeDateModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInboxIssueDelete = async () => {
|
||||||
|
if (!inboxIssue || !currentInboxIssueId) return;
|
||||||
|
deleteInboxIssue(workspaceSlug, projectId, currentInboxIssueId).finally(() => {
|
||||||
|
router.push(`/${workspaceSlug}/projects/${projectId}/inbox`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyIssueLink = () =>
|
||||||
|
copyUrlToClipboard(issueLink).then(() =>
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: "Link copied",
|
||||||
|
message: "Issue link copied to clipboard",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentIssueIndex = inboxIssuesArray.findIndex((issue) => issue.issue.id === currentInboxIssueId) ?? 0;
|
||||||
|
|
||||||
|
const handleInboxIssueNavigation = useCallback(
|
||||||
|
(direction: "next" | "prev") => {
|
||||||
|
if (!inboxIssuesArray || !currentInboxIssueId) return;
|
||||||
|
const activeElement = document.activeElement as HTMLElement;
|
||||||
|
if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return;
|
||||||
|
const nextIssueIndex =
|
||||||
|
direction === "next"
|
||||||
|
? (currentIssueIndex + 1) % inboxIssuesArray.length
|
||||||
|
: (currentIssueIndex - 1 + inboxIssuesArray.length) % inboxIssuesArray.length;
|
||||||
|
const nextIssueId = inboxIssuesArray[nextIssueIndex].issue.id;
|
||||||
|
if (!nextIssueId) return;
|
||||||
|
router.push(`/${workspaceSlug}/projects/${projectId}/inbox?inboxIssueId=${nextIssueId}`);
|
||||||
|
},
|
||||||
|
[currentInboxIssueId, currentIssueIndex, inboxIssuesArray, projectId, router, workspaceSlug]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
handleInboxIssueNavigation("prev");
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
handleInboxIssueNavigation("next");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleInboxIssueNavigation]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [onKeyDown]);
|
||||||
|
|
||||||
|
if (!inboxIssue) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<>
|
||||||
|
<SelectDuplicateInboxIssueModal
|
||||||
|
isOpen={selectDuplicateIssue}
|
||||||
|
onClose={() => setSelectDuplicateIssue(false)}
|
||||||
|
value={inboxIssue?.duplicate_to}
|
||||||
|
onSubmit={handleInboxIssueDuplicate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AcceptIssueModal
|
||||||
|
data={inboxIssue?.issue}
|
||||||
|
isOpen={acceptIssueModal}
|
||||||
|
onClose={() => setAcceptIssueModal(false)}
|
||||||
|
onSubmit={handleInboxIssueAccept}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeclineIssueModal
|
||||||
|
data={inboxIssue?.issue || {}}
|
||||||
|
isOpen={declineIssueModal}
|
||||||
|
onClose={() => setDeclineIssueModal(false)}
|
||||||
|
onSubmit={handleInboxIssueDecline}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteInboxIssueModal
|
||||||
|
data={inboxIssue?.issue}
|
||||||
|
isOpen={deleteIssueModal}
|
||||||
|
onClose={() => setDeleteIssueModal(false)}
|
||||||
|
onSubmit={handleInboxIssueDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InboxIssueSnoozeModal
|
||||||
|
isOpen={isSnoozeDateModalOpen}
|
||||||
|
handleClose={() => setIsSnoozeDateModalOpen(false)}
|
||||||
|
value={inboxIssue?.snoozed_till}
|
||||||
|
onConfirm={handleInboxSIssueSnooze}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
|
||||||
|
<div className="relative flex h-full w-full items-center justify-between gap-2 px-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{issue?.project_id && issue.sequence_id && (
|
||||||
|
<h3 className="text-base font-medium text-custom-text-300 flex-shrink-0">
|
||||||
|
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<InboxIssueStatus inboxIssue={inboxIssue} />
|
||||||
|
<div className="flex items-center justify-end w-full">
|
||||||
|
<IssueUpdateStatus isSubmitting={isSubmitting} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-custom-border-200 p-1.5"
|
||||||
|
onClick={() => handleInboxIssueNavigation("prev")}
|
||||||
|
>
|
||||||
|
<ChevronUp size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-custom-border-200 p-1.5"
|
||||||
|
onClick={() => handleInboxIssueNavigation("next")}
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{canMarkAsAccepted && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Button variant="neutral-primary" size="sm" onClick={() => setAcceptIssueModal(true)}>
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canMarkAsDeclined && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Button variant="neutral-primary" size="sm" onClick={() => setDeclineIssueModal(true)}>
|
||||||
|
Decline
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCompleted ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="neutral-primary"
|
||||||
|
prependIcon={<Link className="h-2.5 w-2.5" />}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyIssueLink}
|
||||||
|
>
|
||||||
|
Copy issue link
|
||||||
|
</Button>
|
||||||
|
<ControlLink
|
||||||
|
href={`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`}
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button variant="neutral-primary" prependIcon={<ExternalLink className="h-2.5 w-2.5" />} size="sm">
|
||||||
|
Open issue
|
||||||
|
</Button>
|
||||||
|
</ControlLink>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CustomMenu verticalEllipsis placement="bottom-start">
|
||||||
|
{canMarkAsAccepted && (
|
||||||
|
<CustomMenu.MenuItem onClick={() => setIsSnoozeDateModalOpen(true)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock size={14} strokeWidth={2} />
|
||||||
|
Snooze
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
{canMarkAsDuplicate && (
|
||||||
|
<CustomMenu.MenuItem onClick={() => setSelectDuplicateIssue(true)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileStack size={14} strokeWidth={2} />
|
||||||
|
Mark as duplicate
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trash2 size={14} strokeWidth={2} />
|
||||||
|
Delete
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
</CustomMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
4
web/components/inbox/content/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./root";
|
||||||
|
export * from "./inbox-issue-header";
|
||||||
|
export * from "./issue-properties";
|
||||||
|
export * from "./issue-root";
|
@ -1,60 +1,32 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
|
|
||||||
import { CalendarCheck2, Signal, Tag } from "lucide-react";
|
import { CalendarCheck2, Signal, Tag } from "lucide-react";
|
||||||
|
import { TIssue } from "@plane/types";
|
||||||
// hooks
|
import { DoubleCircleIcon, UserGroupIcon } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui";
|
|
||||||
import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns";
|
import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns";
|
||||||
import { IssueLabel, TIssueOperations } from "@/components/issues";
|
import { IssueLabel, TIssueOperations } from "@/components/issues";
|
||||||
// icons
|
|
||||||
// helper
|
// helper
|
||||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||||
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
issueId: string;
|
issue: Partial<TIssue>;
|
||||||
issueOperations: TIssueOperations;
|
issueOperations: TIssueOperations;
|
||||||
is_editable: boolean;
|
is_editable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
export const InboxIssueProperties: React.FC<Props> = observer((props) => {
|
||||||
const { workspaceSlug, projectId, issueId, issueOperations, is_editable } = props;
|
const { workspaceSlug, projectId, issue, issueOperations, is_editable } = props;
|
||||||
// store hooks
|
|
||||||
const { getProjectById } = useProject();
|
|
||||||
const { projectStates } = useProjectState();
|
|
||||||
const {
|
|
||||||
issue: { getIssueById },
|
|
||||||
} = useIssueDetail();
|
|
||||||
|
|
||||||
const issue = getIssueById(issueId);
|
|
||||||
if (!issue) return <></>;
|
|
||||||
|
|
||||||
const projectDetails = issue ? getProjectById(issue.project_id) : null;
|
|
||||||
|
|
||||||
const minDate = issue.start_date ? getDate(issue.start_date) : null;
|
const minDate = issue.start_date ? getDate(issue.start_date) : null;
|
||||||
minDate?.setDate(minDate.getDate());
|
minDate?.setDate(minDate.getDate());
|
||||||
|
|
||||||
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
|
if (!issue || !issue?.id) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
|
<div className="flex h-min w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
|
||||||
<div className="flex items-center justify-between px-5 pb-3">
|
<div className="h-min w-full overflow-y-auto px-5">
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
{currentIssueState && (
|
|
||||||
<StateGroupIcon className="h-4 w-4" stateGroup={currentIssueState.group} color={currentIssueState.color} />
|
|
||||||
)}
|
|
||||||
<h4 className="text-lg font-medium text-custom-text-300">
|
|
||||||
{projectDetails?.identifier}-{issue?.sequence_id}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-full w-full overflow-y-auto px-5">
|
|
||||||
<h5 className="text-sm font-medium my-4">Properties</h5>
|
<h5 className="text-sm font-medium my-4">Properties</h5>
|
||||||
<div className={`divide-y-2 divide-custom-border-200 ${!is_editable ? "opacity-60" : ""}`}>
|
<div className={`divide-y-2 divide-custom-border-200 ${!is_editable ? "opacity-60" : ""}`}>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@ -64,9 +36,12 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
|
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>State</span>
|
<span>State</span>
|
||||||
</div>
|
</div>
|
||||||
|
{issue?.state_id && (
|
||||||
<StateDropdown
|
<StateDropdown
|
||||||
value={issue?.state_id ?? undefined}
|
value={issue?.state_id}
|
||||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })}
|
onChange={(val) =>
|
||||||
|
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { state_id: val })
|
||||||
|
}
|
||||||
projectId={projectId?.toString() ?? ""}
|
projectId={projectId?.toString() ?? ""}
|
||||||
disabled={!is_editable}
|
disabled={!is_editable}
|
||||||
buttonVariant="transparent-with-text"
|
buttonVariant="transparent-with-text"
|
||||||
@ -76,6 +51,7 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
dropdownArrow
|
dropdownArrow
|
||||||
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Assignee */}
|
{/* Assignee */}
|
||||||
<div className="flex items-center gap-2 h-8">
|
<div className="flex items-center gap-2 h-8">
|
||||||
@ -84,17 +60,21 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
<span>Assignees</span>
|
<span>Assignees</span>
|
||||||
</div>
|
</div>
|
||||||
<MemberDropdown
|
<MemberDropdown
|
||||||
value={issue?.assignee_ids ?? undefined}
|
value={issue?.assignee_ids ?? []}
|
||||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
|
onChange={(val) =>
|
||||||
|
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { assignee_ids: val })
|
||||||
|
}
|
||||||
disabled={!is_editable}
|
disabled={!is_editable}
|
||||||
projectId={projectId?.toString() ?? ""}
|
projectId={projectId?.toString() ?? ""}
|
||||||
placeholder="Add assignees"
|
placeholder="Add assignees"
|
||||||
multiple
|
multiple
|
||||||
buttonVariant={issue?.assignee_ids?.length > 0 ? "transparent-without-text" : "transparent-with-text"}
|
buttonVariant={
|
||||||
|
(issue?.assignee_ids || [])?.length > 0 ? "transparent-without-text" : "transparent-with-text"
|
||||||
|
}
|
||||||
className="w-3/5 flex-grow group"
|
className="w-3/5 flex-grow group"
|
||||||
buttonContainerClassName="w-full text-left"
|
buttonContainerClassName="w-full text-left"
|
||||||
buttonClassName={`text-sm justify-between ${
|
buttonClassName={`text-sm justify-between ${
|
||||||
issue?.assignee_ids.length > 0 ? "" : "text-custom-text-400"
|
(issue?.assignee_ids || [])?.length > 0 ? "" : "text-custom-text-400"
|
||||||
}`}
|
}`}
|
||||||
hideIcon={issue.assignee_ids?.length === 0}
|
hideIcon={issue.assignee_ids?.length === 0}
|
||||||
dropdownArrow
|
dropdownArrow
|
||||||
@ -108,8 +88,10 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
<span>Priority</span>
|
<span>Priority</span>
|
||||||
</div>
|
</div>
|
||||||
<PriorityDropdown
|
<PriorityDropdown
|
||||||
value={issue?.priority || undefined}
|
value={issue?.priority || "none"}
|
||||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })}
|
onChange={(val) =>
|
||||||
|
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { priority: val })
|
||||||
|
}
|
||||||
disabled={!is_editable}
|
disabled={!is_editable}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
className="w-3/5 flex-grow rounded px-2 hover:bg-custom-background-80"
|
className="w-3/5 flex-grow rounded px-2 hover:bg-custom-background-80"
|
||||||
@ -129,9 +111,10 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
<DateDropdown
|
<DateDropdown
|
||||||
placeholder="Add due date"
|
placeholder="Add due date"
|
||||||
value={issue.target_date}
|
value={issue.target_date || null}
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
issueOperations.update(workspaceSlug, projectId, issueId, {
|
issue?.id &&
|
||||||
|
issueOperations.update(workspaceSlug, projectId, issue?.id, {
|
||||||
target_date: val ? renderFormattedPayloadDate(val) : null,
|
target_date: val ? renderFormattedPayloadDate(val) : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -152,16 +135,18 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
<span>Labels</span>
|
<span>Labels</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-3/5 flex-grow min-h-8 h-full pt-1">
|
<div className="w-3/5 flex-grow min-h-8 h-full pt-1">
|
||||||
|
{issue?.id && (
|
||||||
<IssueLabel
|
<IssueLabel
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
issueId={issueId}
|
issueId={issue?.id}
|
||||||
disabled={!is_editable}
|
disabled={!is_editable}
|
||||||
isInboxIssue
|
isInboxIssue
|
||||||
onLabelUpdate={(val: string[]) =>
|
onLabelUpdate={(val: string[]) =>
|
||||||
issueOperations.update(workspaceSlug, projectId, issueId, { label_ids: val })
|
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { label_ids: val })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
174
web/components/inbox/content/issue-root.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { Dispatch, SetStateAction, useEffect, useMemo } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { TIssue } from "@plane/types";
|
||||||
|
import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { InboxIssueProperties } from "@/components/inbox/content";
|
||||||
|
import {
|
||||||
|
IssueDescriptionInput,
|
||||||
|
IssueTitleInput,
|
||||||
|
IssueActivity,
|
||||||
|
IssueReaction,
|
||||||
|
TIssueOperations,
|
||||||
|
} from "@/components/issues";
|
||||||
|
// hooks
|
||||||
|
import { useEventTracker, useProjectInbox, useUser } from "@/hooks/store";
|
||||||
|
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||||
|
// store types
|
||||||
|
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
inboxIssue: IInboxIssueStore;
|
||||||
|
is_editable: boolean;
|
||||||
|
isSubmitting: "submitting" | "submitted" | "saved";
|
||||||
|
setIsSubmitting: Dispatch<SetStateAction<"submitting" | "submitted" | "saved">>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, inboxIssue, is_editable, isSubmitting, setIsSubmitting } = props;
|
||||||
|
// hooks
|
||||||
|
const { currentUser } = useUser();
|
||||||
|
const { isLoading } = useProjectInbox();
|
||||||
|
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
|
||||||
|
const { captureIssueEvent } = useEventTracker();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSubmitting === "submitted") {
|
||||||
|
setShowAlert(false);
|
||||||
|
setTimeout(async () => {
|
||||||
|
setIsSubmitting("saved");
|
||||||
|
}, 3000);
|
||||||
|
} else if (isSubmitting === "submitting") {
|
||||||
|
setShowAlert(true);
|
||||||
|
}
|
||||||
|
}, [isSubmitting, setShowAlert, setIsSubmitting]);
|
||||||
|
|
||||||
|
const issue = inboxIssue.issue;
|
||||||
|
if (!issue) return <></>;
|
||||||
|
|
||||||
|
const issueDescription =
|
||||||
|
issue.description_html !== undefined || issue.description_html !== null
|
||||||
|
? issue.description_html != ""
|
||||||
|
? issue.description_html
|
||||||
|
: "<p></p>"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const issueOperations: TIssueOperations = useMemo(
|
||||||
|
() => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
fetch: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||||
|
try {
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
setToast({
|
||||||
|
title: "Issue fetch failed",
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
message: "Issue fetch failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||||
|
try {
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
setToast({
|
||||||
|
title: "Issue remove failed",
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
message: "Issue remove failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||||
|
try {
|
||||||
|
await inboxIssue.updateIssue(data);
|
||||||
|
captureIssueEvent({
|
||||||
|
eventName: "Inbox issue updated",
|
||||||
|
payload: { ...data, state: "SUCCESS", element: "Inbox" },
|
||||||
|
updates: {
|
||||||
|
changed_property: Object.keys(data).join(","),
|
||||||
|
change_details: Object.values(data).join(","),
|
||||||
|
},
|
||||||
|
path: router.asPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setToast({
|
||||||
|
title: "Issue update failed",
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
message: "Issue update failed",
|
||||||
|
});
|
||||||
|
captureIssueEvent({
|
||||||
|
eventName: "Inbox issue updated",
|
||||||
|
payload: { state: "SUCCESS", element: "Inbox" },
|
||||||
|
updates: {
|
||||||
|
changed_property: Object.keys(data).join(","),
|
||||||
|
change_details: Object.values(data).join(","),
|
||||||
|
},
|
||||||
|
path: router.asPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[inboxIssue]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!issue?.project_id || !issue?.id) return <></>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg space-y-4">
|
||||||
|
<IssueTitleInput
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={issue.project_id}
|
||||||
|
issueId={issue.id}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||||
|
issueOperations={issueOperations}
|
||||||
|
disabled={!is_editable}
|
||||||
|
value={issue.name}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader className="h-[150px] space-y-2 overflow-hidden rounded-md border border-custom-border-200 p-2 py-2">
|
||||||
|
<Loader.Item width="100%" height="132px" />
|
||||||
|
</Loader>
|
||||||
|
) : (
|
||||||
|
<IssueDescriptionInput
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={issue.project_id}
|
||||||
|
issueId={issue.id}
|
||||||
|
value={issueDescription}
|
||||||
|
initialValue={issueDescription}
|
||||||
|
disabled={!is_editable}
|
||||||
|
issueOperations={issueOperations}
|
||||||
|
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentUser && (
|
||||||
|
<IssueReaction
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
issueId={issue.id}
|
||||||
|
currentUser={currentUser}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InboxIssueProperties
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
issue={issue}
|
||||||
|
issueOperations={issueOperations}
|
||||||
|
is_editable={is_editable}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pb-12">
|
||||||
|
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -1,86 +1,62 @@
|
|||||||
import { FC } from "react";
|
import { FC, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Inbox } from "lucide-react";
|
import useSWR from "swr";
|
||||||
// hooks
|
import { InboxIssueActionsHeader, InboxIssueMainContent } from "@/components/inbox";
|
||||||
import { Loader } from "@plane/ui";
|
import { EUserProjectRoles } from "@/constants/project";
|
||||||
import { InboxIssueActionsHeader } from "@/components/inbox";
|
import { useProjectInbox, useUser } from "@/hooks/store";
|
||||||
import { InboxIssueDetailRoot } from "@/components/issues/issue-detail/inbox";
|
|
||||||
import { useInboxIssues } from "@/hooks/store";
|
|
||||||
// components
|
|
||||||
// ui
|
|
||||||
|
|
||||||
type TInboxContentRoot = {
|
type TInboxContentRoot = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
inboxId: string;
|
inboxIssueId: string;
|
||||||
inboxIssueId: string | undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
|
export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
|
||||||
const { workspaceSlug, projectId, inboxId, inboxIssueId } = props;
|
const { workspaceSlug, projectId, inboxIssueId } = props;
|
||||||
|
// states
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||||
// hooks
|
// hooks
|
||||||
|
const { fetchInboxIssueById, getIssueInboxByIssueId } = useProjectInbox();
|
||||||
|
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
|
||||||
const {
|
const {
|
||||||
issues: { loader, getInboxIssuesByInboxId },
|
membership: { currentProjectRole },
|
||||||
} = useInboxIssues();
|
} = useUser();
|
||||||
|
|
||||||
const inboxIssuesList = inboxId ? getInboxIssuesByInboxId(inboxId) : undefined;
|
useSWR(
|
||||||
|
workspaceSlug && projectId && inboxIssueId
|
||||||
|
? `PROJECT_INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
|
||||||
|
: null,
|
||||||
|
() => {
|
||||||
|
workspaceSlug && projectId && inboxIssueId && fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId);
|
||||||
|
},
|
||||||
|
{ revalidateOnFocus: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||||
|
|
||||||
|
if (!inboxIssue) return <></>;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{loader === "init-loader" ? (
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{!inboxIssueId ? (
|
|
||||||
<div className="grid h-full place-items-center p-4 text-custom-text-200">
|
|
||||||
<div className="grid h-full place-items-center">
|
|
||||||
<div className="my-5 flex flex-col items-center gap-4">
|
|
||||||
<Inbox size={60} strokeWidth={1.5} />
|
|
||||||
{inboxIssuesList && inboxIssuesList.length > 0 ? (
|
|
||||||
<span className="text-custom-text-200">
|
|
||||||
{inboxIssuesList?.length} issues found. Select an issue from the sidebar to view its details.
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-custom-text-200">No issues found</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full overflow-hidden relative flex flex-col">
|
<div className="w-full h-full overflow-hidden relative flex flex-col">
|
||||||
<div className="flex-shrink-0 min-h-[50px] border-b border-custom-border-300">
|
<div className="flex-shrink-0 min-h-[50px] border-b border-custom-border-300">
|
||||||
<InboxIssueActionsHeader
|
<InboxIssueActionsHeader
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
inboxId={inboxId}
|
inboxIssue={inboxIssue}
|
||||||
inboxIssueId={inboxIssueId}
|
isSubmitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-full">
|
<div className="h-full w-full space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5 vertical-scrollbar scrollbar-md">
|
||||||
<InboxIssueDetailRoot
|
<InboxIssueMainContent
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
inboxId={inboxId}
|
inboxIssue={inboxIssue}
|
||||||
issueId={inboxIssueId}
|
is_editable={is_editable}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
setIsSubmitting={setIsSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
66
web/components/inbox/inbox-filter/applied-filters/date.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { TInboxIssueFilterDateKeys } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
|
||||||
|
// helpers
|
||||||
|
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||||
|
// hooks
|
||||||
|
import { useProjectInbox } from "@/hooks/store";
|
||||||
|
|
||||||
|
type InboxIssueAppliedFiltersDate = {
|
||||||
|
filterKey: TInboxIssueFilterDateKeys;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InboxIssueAppliedFiltersDate: FC<InboxIssueAppliedFiltersDate> = observer((props) => {
|
||||||
|
const { filterKey, label } = props;
|
||||||
|
// hooks
|
||||||
|
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||||
|
// derived values
|
||||||
|
const filteredValues = inboxFilters?.[filterKey] || [];
|
||||||
|
const currentOptionDetail = (date: string) => {
|
||||||
|
const currentDate = DATE_BEFORE_FILTER_OPTIONS.find((d) => d.value === date);
|
||||||
|
if (currentDate) return currentDate;
|
||||||
|
const dateSplit = date.split(";");
|
||||||
|
return {
|
||||||
|
name: `${dateSplit[1].charAt(0).toUpperCase() + dateSplit[1].slice(1)} ${renderFormattedDate(dateSplit[0])}`,
|
||||||
|
value: date,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterValue = (value: string): string[] =>
|
||||||
|
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
|
||||||
|
|
||||||
|
const clearFilter = () => handleInboxIssueFilters(filterKey, undefined);
|
||||||
|
|
||||||
|
if (filteredValues.length === 0) return <></>;
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1">
|
||||||
|
<div className="text-xs text-custom-text-200">{label}</div>
|
||||||
|
{filteredValues.map((value) => {
|
||||||
|
const optionDetail = currentOptionDetail(value);
|
||||||
|
if (!optionDetail) return <></>;
|
||||||
|
return (
|
||||||
|
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||||
|
<div className="text-xs truncate">{optionDetail?.name}</div>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||||
|
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(optionDetail?.value))}
|
||||||
|
>
|
||||||
|
<X className={`w-3 h-3`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||||
|
onClick={clearFilter}
|
||||||
|
>
|
||||||
|
<X className={`w-3 h-3`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,6 @@
|
|||||||
|
export * from "./root";
|
||||||
|
export * from "./status";
|
||||||
|
export * from "./priority";
|
||||||
|
export * from "./member";
|
||||||
|
export * from "./label";
|
||||||
|
export * from "./date";
|
55
web/components/inbox/inbox-filter/applied-filters/label.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import { useLabel, useProjectInbox } from "@/hooks/store";
|
||||||
|
|
||||||
|
const LabelIcons = ({ color }: { color: string }) => (
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const InboxIssueAppliedFiltersLabel: FC = observer(() => {
|
||||||
|
// hooks
|
||||||
|
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||||
|
const { getLabelById } = useLabel();
|
||||||
|
// derived values
|
||||||
|
const filteredValues = inboxFilters?.label || [];
|
||||||
|
const currentOptionDetail = (labelId: string) => getLabelById(labelId) || undefined;
|
||||||
|
|
||||||
|
const handleFilterValue = (value: string): string[] =>
|
||||||
|
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
|
||||||
|
|
||||||
|
const clearFilter = () => handleInboxIssueFilters("label", undefined);
|
||||||
|
|
||||||
|
if (filteredValues.length === 0) return <></>;
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1">
|
||||||
|
<div className="text-xs text-custom-text-200">Label</div>
|
||||||
|
{filteredValues.map((value) => {
|
||||||
|
const optionDetail = currentOptionDetail(value);
|
||||||
|
if (!optionDetail) return <></>;
|
||||||
|
return (
|
||||||
|
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||||
|
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden">
|
||||||
|
<LabelIcons color={optionDetail.color} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs truncate">{optionDetail?.name}</div>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||||
|
onClick={() => handleInboxIssueFilters("label", handleFilterValue(value))}
|
||||||
|
>
|
||||||
|
<X className={`w-3 h-3`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||||
|
onClick={clearFilter}
|
||||||
|
>
|
||||||
|
<X className={`w-3 h-3`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
59
web/components/inbox/inbox-filter/applied-filters/member.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { TInboxIssueFilterMemberKeys } from "@plane/types";
|
||||||
|
import { Avatar } from "@plane/ui";
|
||||||
|
// hooks
|
||||||
|
import { useMember, useProjectInbox } from "@/hooks/store";
|
||||||
|
|
||||||
|
type InboxIssueAppliedFiltersMember = {
|
||||||
|
filterKey: TInboxIssueFilterMemberKeys;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InboxIssueAppliedFiltersMember: FC<InboxIssueAppliedFiltersMember> = observer((props) => {
|
||||||
|
const { filterKey, label } = props;
|
||||||
|
// hooks
|
||||||
|
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||||
|
const { getUserDetails } = useMember();
|
||||||
|
// derived values
|
||||||
|
const filteredValues = inboxFilters?.[filterKey] || [];
|
||||||
|
const currentOptionDetail = (memberId: string) => getUserDetails(memberId) || undefined;
|
||||||
|
|
||||||
|
const handleFilterValue = (value: string): string[] =>
|
||||||
|
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
|
||||||
|
|
||||||
|
const clearFilter = () => handleInboxIssueFilters(filterKey, undefined);
|
||||||
|
|
||||||
|
if (filteredValues.length === 0) return <></>;
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1">
|
||||||
|
<div className="text-xs text-custom-text-200">{label}</div>
|
||||||
|
{filteredValues.map((value) => {
|
||||||
|
const optionDetail = currentOptionDetail(value);
|
||||||
|
if (!optionDetail) return <></>;
|
||||||
|
return (
|
||||||
|
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||||
|
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden">
|
||||||
|
<Avatar name={optionDetail.display_name} src={optionDetail.avatar} showTooltip={false} size="md" />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs truncate">{optionDetail?.display_name}</div>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||||
|
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(value))}
|
||||||
|
>
|
||||||
|
<X className={`w-3 h-3`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||||
|
onClick={clearFilter}
|
||||||
|
>
|
||||||
|
<X className={`w-3 h-3`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,55 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { TIssuePriorities } from "@plane/types";
|
||||||
|
import { PriorityIcon } from "@plane/ui";
|
||||||
|
// constants
|
||||||
|
import { ISSUE_PRIORITIES } from "@/constants/issue";
|
||||||
|
// hooks
|
||||||
|
import { useProjectInbox } from "@/hooks/store";
|
||||||
|
|
||||||
|
export const InboxIssueAppliedFiltersPriority: FC = observer(() => {
|
||||||
|
// hooks
|
||||||
|
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||||
|
// derived values
|
||||||
|
const filteredValues = inboxFilters?.priority || [];
|
||||||
|
const currentOptionDetail = (priority: TIssuePriorities) =>
|
||||||
|
ISSUE_PRIORITIES.find((p) => p.key === priority) || undefined;
|
||||||
|
|
||||||
|
const handleFilterValue = (value: TIssuePriorities): TIssuePriorities[] =>
|
||||||
|
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
|
||||||
|
|
||||||
|
const clearFilter = () => handleInboxIssueFilters("priority", undefined);
|
||||||
|
|
||||||
|
if (filteredValues.length === 0) return <></>;
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1">
|
||||||
|
<div className="text-xs text-custom-text-200">Priority</div>
|
||||||
|
{filteredValues.map((value) => {
|
||||||
|
const optionDetail = currentOptionDetail(value);
|
||||||
|
if (!optionDetail) return <></>;
|
||||||
|
return (
|
||||||
|
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||||
|
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden">
|
||||||
|
<PriorityIcon priority={optionDetail.key} className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs truncate">{optionDetail?.title}</div>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||||
|
onClick={() => handleInboxIssueFilters("priority", handleFilterValue(optionDetail?.key))}
|
||||||
|
>
|
||||||
|
<X className={`w-3 h-3`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||||
|
onClick={clearFilter}
|
||||||
|
>
|
||||||
|
<X className={`w-3 h-3`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
36
web/components/inbox/inbox-filter/applied-filters/root.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
InboxIssueAppliedFiltersStatus,
|
||||||
|
InboxIssueAppliedFiltersPriority,
|
||||||
|
InboxIssueAppliedFiltersMember,
|
||||||
|
InboxIssueAppliedFiltersLabel,
|
||||||
|
InboxIssueAppliedFiltersDate,
|
||||||
|
} from "@/components/inbox";
|
||||||
|
// hooks
|
||||||
|
import { useProjectInbox } from "@/hooks/store";
|
||||||
|
|
||||||
|
export const InboxIssueAppliedFilters: FC = observer(() => {
|
||||||
|
const { getAppliedFiltersCount } = useProjectInbox();
|
||||||
|
|
||||||
|
if (getAppliedFiltersCount === 0) return <></>;
|
||||||
|
return (
|
||||||
|
<div className="p-3 py-2 relative flex flex-wrap items-center gap-1 border-b border-custom-border-300">
|
||||||
|
{/* status */}
|
||||||
|
<InboxIssueAppliedFiltersStatus />
|
||||||
|
{/* priority */}
|
||||||
|
<InboxIssueAppliedFiltersPriority />
|
||||||
|
{/* assignees */}
|
||||||
|
<InboxIssueAppliedFiltersMember filterKey="assignee" label="Assignee" />
|
||||||
|
{/* created_by */}
|
||||||
|
<InboxIssueAppliedFiltersMember filterKey="created_by" label="Created By" />
|
||||||
|
{/* label */}
|
||||||
|
<InboxIssueAppliedFiltersLabel />
|
||||||
|
{/* created_at */}
|
||||||
|
<InboxIssueAppliedFiltersDate filterKey="created_at" label="Created At" />
|
||||||
|
{/* updated_at */}
|
||||||
|
<InboxIssueAppliedFiltersDate filterKey="updated_at" label="Updated At" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
57
web/components/inbox/inbox-filter/applied-filters/status.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { TInboxIssueStatus } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { INBOX_STATUS } from "@/constants/inbox";
|
||||||
|
// hooks
|
||||||
|
import { useProjectInbox } from "@/hooks/store";
|
||||||
|
|
||||||
|
export const InboxIssueAppliedFiltersStatus: FC = observer(() => {
|
||||||
|
// hooks
|
||||||
|
const { currentTab, inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||||
|
// derived values
|
||||||
|
const filteredValues = inboxFilters?.status || [];
|
||||||
|
const currentOptionDetail = (status: TInboxIssueStatus) => INBOX_STATUS.find((s) => s.status === status) || undefined;
|
||||||
|
|
||||||
|
const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] =>
|
||||||
|
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
|
||||||
|
|
||||||
|
const clearFilter = () => handleInboxIssueFilters("status", undefined);
|
||||||
|
|
||||||
|
if (filteredValues.length === 0) return <></>;
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1">
|
||||||
|
<div className="text-xs text-custom-text-200">Status</div>
|
||||||
|
{filteredValues.map((value) => {
|
||||||
|
const optionDetail = currentOptionDetail(value);
|
||||||
|
if (!optionDetail) return <></>;
|
||||||
|
return (
|
||||||
|
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||||
|
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden">
|
||||||
|
<optionDetail.icon className={`w-3 h-3 ${optionDetail?.textColor(false)}`} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs truncate">{optionDetail?.title}</div>
|
||||||
|
{currentTab === "closed" && handleFilterValue(optionDetail?.status).length >= 1 && (
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||||
|
onClick={() => handleInboxIssueFilters("status", handleFilterValue(optionDetail?.status))}
|
||||||
|
>
|
||||||
|
<X className={`w-3 h-3`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{currentTab === "closed" && filteredValues.length > 1 && (
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
|
||||||
|
onClick={clearFilter}
|
||||||
|
>
|
||||||
|
<X className={`w-3 h-3`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
97
web/components/inbox/inbox-filter/filters/date.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { FC, useState } from "react";
|
||||||
|
import concat from "lodash/concat";
|
||||||
|
import pull from "lodash/pull";
|
||||||
|
import uniq from "lodash/uniq";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { TInboxIssueFilterDateKeys } from "@plane/types";
|
||||||
|
// components
|
||||||
|
import { DateFilterModal } from "@/components/core";
|
||||||
|
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||||
|
// constants
|
||||||
|
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
|
||||||
|
// hooks
|
||||||
|
import { useProjectInbox } from "@/hooks/store";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
filterKey: TInboxIssueFilterDateKeys;
|
||||||
|
label?: string;
|
||||||
|
searchQuery: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDate = (date: string) => {
|
||||||
|
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
return datePattern.test(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilterDate: FC<Props> = observer((props) => {
|
||||||
|
const { filterKey, label, searchQuery } = props;
|
||||||
|
// hooks
|
||||||
|
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||||
|
// state
|
||||||
|
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||||
|
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
|
||||||
|
// derived values
|
||||||
|
const filterValue: string[] = inboxFilters?.[filterKey] || [];
|
||||||
|
const appliedFiltersCount = filterValue?.length ?? 0;
|
||||||
|
const filteredOptions = DATE_BEFORE_FILTER_OPTIONS.filter((d) =>
|
||||||
|
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterValue = (value: string): string[] =>
|
||||||
|
filterValue?.includes(value) ? pull(filterValue, value) : uniq(concat(filterValue, value));
|
||||||
|
|
||||||
|
const handleCustomFilterValue = (value: string[]): string[] => {
|
||||||
|
const finalOptions: string[] = [...filterValue];
|
||||||
|
value.forEach((v) => (finalOptions?.includes(v) ? pull(finalOptions, v) : finalOptions.push(v)));
|
||||||
|
return uniq(finalOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCustomDateSelected = () => {
|
||||||
|
const isValidDateSelected = filterValue?.filter((f) => isDate(f.split(";")[0])) || [];
|
||||||
|
return isValidDateSelected.length > 0 ? true : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomDate = () => {
|
||||||
|
if (isCustomDateSelected()) {
|
||||||
|
const updateAppliedFilters = filterValue?.filter((f) => isDate(f.split(";")[0])) || [];
|
||||||
|
handleInboxIssueFilters(filterKey, handleCustomFilterValue(updateAppliedFilters));
|
||||||
|
} else setIsDateFilterModalOpen(true);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isDateFilterModalOpen && (
|
||||||
|
<DateFilterModal
|
||||||
|
handleClose={() => setIsDateFilterModalOpen(false)}
|
||||||
|
isOpen={isDateFilterModalOpen}
|
||||||
|
onSelect={(val) => handleInboxIssueFilters(filterKey, handleCustomFilterValue(val))}
|
||||||
|
title="Created date"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FilterHeader
|
||||||
|
title={`${label || "Created date"}${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||||
|
isPreviewEnabled={previewEnabled}
|
||||||
|
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||||
|
/>
|
||||||
|
{previewEnabled && (
|
||||||
|
<div>
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{filteredOptions.map((option) => (
|
||||||
|
<FilterOption
|
||||||
|
key={option.value}
|
||||||
|
isChecked={filterValue?.includes(option.value) ? true : false}
|
||||||
|
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(option.value))}
|
||||||
|
title={option.name}
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -0,0 +1,87 @@
|
|||||||
|
import { FC, useState } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
FilterStatus,
|
||||||
|
FilterPriority,
|
||||||
|
FilterMember,
|
||||||
|
FilterDate,
|
||||||
|
FilterLabels,
|
||||||
|
} from "@/components/inbox/inbox-filter/filters";
|
||||||
|
// hooks
|
||||||
|
import { useMember, useLabel } from "@/hooks/store";
|
||||||
|
|
||||||
|
export const InboxIssueFilterSelection: FC = observer(() => {
|
||||||
|
// hooks
|
||||||
|
const {
|
||||||
|
project: { projectMemberIds },
|
||||||
|
} = useMember();
|
||||||
|
const { projectLabels } = useLabel();
|
||||||
|
// states
|
||||||
|
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||||
|
<div className="bg-custom-background-100 p-2.5 pb-0">
|
||||||
|
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
|
||||||
|
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
|
||||||
|
placeholder="Search"
|
||||||
|
value={filtersSearchQuery}
|
||||||
|
onChange={(e) => setFiltersSearchQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{filtersSearchQuery !== "" && (
|
||||||
|
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
|
||||||
|
<X className="text-custom-text-300" size={12} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
|
||||||
|
{/* status */}
|
||||||
|
<div className="py-2">
|
||||||
|
<FilterStatus searchQuery={filtersSearchQuery} />
|
||||||
|
</div>
|
||||||
|
{/* Priority */}
|
||||||
|
<div className="py-2">
|
||||||
|
<FilterPriority searchQuery={filtersSearchQuery} />
|
||||||
|
</div>
|
||||||
|
{/* assignees */}
|
||||||
|
<div className="py-2">
|
||||||
|
<FilterMember
|
||||||
|
filterKey="assignee"
|
||||||
|
label="Assignee"
|
||||||
|
searchQuery={filtersSearchQuery}
|
||||||
|
memberIds={projectMemberIds ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Created By */}
|
||||||
|
<div className="py-2">
|
||||||
|
<FilterMember
|
||||||
|
filterKey="created_by"
|
||||||
|
label="Created By"
|
||||||
|
searchQuery={filtersSearchQuery}
|
||||||
|
memberIds={projectMemberIds ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Labels */}
|
||||||
|
<div className="py-2">
|
||||||
|
<FilterLabels searchQuery={filtersSearchQuery} labels={projectLabels ?? []} />
|
||||||
|
</div>
|
||||||
|
{/* Created at */}
|
||||||
|
<div className="py-2">
|
||||||
|
<FilterDate filterKey="created_at" label="Created at" searchQuery={filtersSearchQuery} />
|
||||||
|
</div>
|
||||||
|
{/* Updated at */}
|
||||||
|
<div className="py-2">
|
||||||
|
<FilterDate filterKey="updated_at" label="Updated at" searchQuery={filtersSearchQuery} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
6
web/components/inbox/inbox-filter/filters/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from "./filter-selection";
|
||||||
|
export * from "./status";
|
||||||
|
export * from "./priority";
|
||||||
|
export * from "./labels";
|
||||||
|
export * from "./members";
|
||||||
|
export * from "./date";
|
88
web/components/inbox/inbox-filter/filters/labels.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { FC, useState } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { IIssueLabel } from "@plane/types";
|
||||||
|
import { Loader } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||||
|
// hooks
|
||||||
|
import { useProjectInbox } from "@/hooks/store";
|
||||||
|
|
||||||
|
const LabelIcons = ({ color }: { color: string }) => (
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||||
|
);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
labels: IIssueLabel[] | undefined;
|
||||||
|
searchQuery: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilterLabels: FC<Props> = observer((props) => {
|
||||||
|
const { labels, searchQuery } = props;
|
||||||
|
|
||||||
|
const [itemsToRender, setItemsToRender] = useState(5);
|
||||||
|
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||||
|
|
||||||
|
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||||
|
|
||||||
|
const filterValue = inboxFilters?.label || [];
|
||||||
|
|
||||||
|
const appliedFiltersCount = filterValue?.length ?? 0;
|
||||||
|
|
||||||
|
const filteredOptions = labels?.filter((label) => label.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
|
|
||||||
|
const handleViewToggle = () => {
|
||||||
|
if (!filteredOptions) return;
|
||||||
|
|
||||||
|
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
|
||||||
|
else setItemsToRender(filteredOptions.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterValue = (value: string): string[] =>
|
||||||
|
filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FilterHeader
|
||||||
|
title={`Label${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||||
|
isPreviewEnabled={previewEnabled}
|
||||||
|
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||||
|
/>
|
||||||
|
{previewEnabled && (
|
||||||
|
<div>
|
||||||
|
{filteredOptions ? (
|
||||||
|
filteredOptions.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{filteredOptions.slice(0, itemsToRender).map((label) => (
|
||||||
|
<FilterOption
|
||||||
|
key={label?.id}
|
||||||
|
isChecked={filterValue?.includes(label?.id) ? true : false}
|
||||||
|
onClick={() => handleInboxIssueFilters("label", handleFilterValue(label.id))}
|
||||||
|
icon={<LabelIcons color={label.color} />}
|
||||||
|
title={label.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{filteredOptions.length > 5 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-8 text-xs font-medium text-custom-primary-100"
|
||||||
|
onClick={handleViewToggle}
|
||||||
|
>
|
||||||
|
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Loader className="space-y-2">
|
||||||
|
<Loader.Item height="20px" />
|
||||||
|
<Loader.Item height="20px" />
|
||||||
|
<Loader.Item height="20px" />
|
||||||
|
</Loader>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
102
web/components/inbox/inbox-filter/filters/members.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { FC, useMemo, useState } from "react";
|
||||||
|
import sortBy from "lodash/sortBy";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { TInboxIssueFilterMemberKeys } from "@plane/types";
|
||||||
|
import { Avatar, Loader } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||||
|
// hooks
|
||||||
|
import { useMember, useProjectInbox } from "@/hooks/store";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
filterKey: TInboxIssueFilterMemberKeys;
|
||||||
|
label?: string;
|
||||||
|
memberIds: string[] | undefined;
|
||||||
|
searchQuery: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilterMember: FC<Props> = observer((props: Props) => {
|
||||||
|
const { filterKey, label = "Members", memberIds, searchQuery } = props;
|
||||||
|
// hooks
|
||||||
|
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||||
|
const { getUserDetails } = useMember();
|
||||||
|
// states
|
||||||
|
const [itemsToRender, setItemsToRender] = useState(5);
|
||||||
|
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||||
|
// derived values
|
||||||
|
const filterValue = inboxFilters?.[filterKey] || [];
|
||||||
|
const appliedFiltersCount = filterValue?.length ?? 0;
|
||||||
|
|
||||||
|
const sortedOptions = useMemo(() => {
|
||||||
|
const filteredOptions = (memberIds || []).filter((memberId) =>
|
||||||
|
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return sortBy(filteredOptions, [
|
||||||
|
(memberId) => !filterValue.includes(memberId),
|
||||||
|
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
|
||||||
|
]);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const handleViewToggle = () => {
|
||||||
|
if (!sortedOptions) return;
|
||||||
|
|
||||||
|
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
|
||||||
|
else setItemsToRender(sortedOptions.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterValue = (value: string): string[] =>
|
||||||
|
filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FilterHeader
|
||||||
|
title={`${label} ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||||
|
isPreviewEnabled={previewEnabled}
|
||||||
|
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||||
|
/>
|
||||||
|
{previewEnabled && (
|
||||||
|
<div>
|
||||||
|
{sortedOptions ? (
|
||||||
|
sortedOptions.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
|
||||||
|
const member = getUserDetails(memberId);
|
||||||
|
|
||||||
|
if (!member) return null;
|
||||||
|
return (
|
||||||
|
<FilterOption
|
||||||
|
key={`members-${member.id}`}
|
||||||
|
isChecked={filterValue?.includes(member.id) ? true : false}
|
||||||
|
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(member.id))}
|
||||||
|
icon={<Avatar name={member.display_name} src={member.avatar} showTooltip={false} size="md" />}
|
||||||
|
title={member.display_name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{sortedOptions.length > 5 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-8 text-xs font-medium text-custom-primary-100"
|
||||||
|
onClick={handleViewToggle}
|
||||||
|
>
|
||||||
|
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Loader className="space-y-2">
|
||||||
|
<Loader.Item height="20px" />
|
||||||
|
<Loader.Item height="20px" />
|
||||||
|
<Loader.Item height="20px" />
|
||||||
|
</Loader>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
56
web/components/inbox/inbox-filter/filters/priority.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { FC, useState } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { TIssuePriorities } from "@plane/types";
|
||||||
|
import { PriorityIcon } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||||
|
// constants
|
||||||
|
import { ISSUE_PRIORITIES } from "@/constants/issue";
|
||||||
|
// hooks
|
||||||
|
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
searchQuery: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilterPriority: FC<Props> = observer((props) => {
|
||||||
|
const { searchQuery } = props;
|
||||||
|
// hooks
|
||||||
|
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||||
|
// states
|
||||||
|
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||||
|
// derived values
|
||||||
|
const filterValue = inboxFilters?.priority || [];
|
||||||
|
const appliedFiltersCount = filterValue?.length ?? 0;
|
||||||
|
const filteredOptions = ISSUE_PRIORITIES.filter((p) => p.key.includes(searchQuery.toLowerCase()));
|
||||||
|
|
||||||
|
const handleFilterValue = (value: TIssuePriorities): TIssuePriorities[] =>
|
||||||
|
filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FilterHeader
|
||||||
|
title={`Priority${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||||
|
isPreviewEnabled={previewEnabled}
|
||||||
|
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||||
|
/>
|
||||||
|
{previewEnabled && (
|
||||||
|
<div>
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((priority) => (
|
||||||
|
<FilterOption
|
||||||
|
key={priority.key}
|
||||||
|
isChecked={filterValue?.includes(priority.key) ? true : false}
|
||||||
|
onClick={() => handleInboxIssueFilters("priority", handleFilterValue(priority.key))}
|
||||||
|
icon={<PriorityIcon priority={priority.key} className="h-3.5 w-3.5" />}
|
||||||
|
title={priority.title}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
68
web/components/inbox/inbox-filter/filters/status.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { FC, useState } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
// types
|
||||||
|
import { TInboxIssueStatus } from "@plane/types";
|
||||||
|
// components
|
||||||
|
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||||
|
// constants
|
||||||
|
import { INBOX_STATUS } from "@/constants/inbox";
|
||||||
|
// hooks
|
||||||
|
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
searchQuery: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FilterStatus: FC<Props> = observer((props) => {
|
||||||
|
const { searchQuery } = props;
|
||||||
|
// hooks
|
||||||
|
const { currentTab, inboxFilters, handleInboxIssueFilters } = useProjectInbox();
|
||||||
|
// states
|
||||||
|
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||||
|
// derived values
|
||||||
|
const filterValue = inboxFilters?.status || [];
|
||||||
|
const appliedFiltersCount = filterValue?.length ?? 0;
|
||||||
|
const filteredOptions = INBOX_STATUS.filter(
|
||||||
|
(s) =>
|
||||||
|
((currentTab === "open" && [-2].includes(s.status)) ||
|
||||||
|
(currentTab === "closed" && [-1, 0, 1, 2].includes(s.status))) &&
|
||||||
|
s.key.includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] =>
|
||||||
|
filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value];
|
||||||
|
|
||||||
|
const handleStatusFilterSelect = (status: TInboxIssueStatus) => {
|
||||||
|
if (currentTab === "closed") {
|
||||||
|
const selectedStatus = handleFilterValue(status);
|
||||||
|
if (selectedStatus.length >= 1) handleInboxIssueFilters("status", selectedStatus);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FilterHeader
|
||||||
|
title={`Issue Status ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||||
|
isPreviewEnabled={previewEnabled}
|
||||||
|
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||||
|
/>
|
||||||
|
{previewEnabled && (
|
||||||
|
<div>
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((status) => (
|
||||||
|
<FilterOption
|
||||||
|
key={status.key}
|
||||||
|
isChecked={filterValue?.includes(status.status) ? true : false}
|
||||||
|
onClick={() => handleStatusFilterSelect(status.status)}
|
||||||
|
icon={<status.icon className={`h-3.5 w-3.5 ${status?.textColor(false)}`} />}
|
||||||
|
title={status.title}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
4
web/components/inbox/inbox-filter/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./root";
|
||||||
|
export * from "./filters";
|
||||||
|
export * from "./sorting";
|
||||||
|
export * from "./applied-filters";
|
18
web/components/inbox/inbox-filter/root.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { ListFilter } from "lucide-react";
|
||||||
|
// components
|
||||||
|
import { InboxIssueFilterSelection, InboxIssueOrderByDropdown } from "@/components/inbox/inbox-filter";
|
||||||
|
import { FiltersDropdown } from "@/components/issues";
|
||||||
|
|
||||||
|
export const FiltersRoot: FC = () => (
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
|
||||||
|
<InboxIssueFilterSelection />
|
||||||
|
</FiltersDropdown>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<InboxIssueOrderByDropdown />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
1
web/components/inbox/inbox-filter/sorting/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./order-by";
|
58
web/components/inbox/inbox-filter/sorting/order-by.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { ArrowDownWideNarrow, ArrowUpWideNarrow, Check, ChevronDown } from "lucide-react";
|
||||||
|
import { CustomMenu, getButtonStyling } from "@plane/ui";
|
||||||
|
// constants
|
||||||
|
import { INBOX_ISSUE_ORDER_BY_OPTIONS, INBOX_ISSUE_SORT_BY_OPTIONS } from "@/constants/inbox";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
// hooks
|
||||||
|
import { useProjectInbox } from "@/hooks/store";
|
||||||
|
|
||||||
|
export const InboxIssueOrderByDropdown: FC = observer(() => {
|
||||||
|
// hooks
|
||||||
|
const { inboxSorting, handleInboxIssueSorting } = useProjectInbox();
|
||||||
|
const orderByDetails =
|
||||||
|
INBOX_ISSUE_ORDER_BY_OPTIONS.find((option) => inboxSorting?.order_by?.includes(option.key)) || undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomMenu
|
||||||
|
customButton={
|
||||||
|
<div className={cn(getButtonStyling("neutral-primary", "sm"), "px-2 text-custom-text-300")}>
|
||||||
|
{inboxSorting?.sort_by === "asc" ? (
|
||||||
|
<ArrowUpWideNarrow className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownWideNarrow className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{orderByDetails?.label || "Order By"}
|
||||||
|
<ChevronDown className="h-3 w-3" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="bottom-end"
|
||||||
|
maxHeight="lg"
|
||||||
|
closeOnSelect
|
||||||
|
>
|
||||||
|
{INBOX_ISSUE_ORDER_BY_OPTIONS.map((option) => (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
key={option.key}
|
||||||
|
className="flex items-center justify-between gap-2"
|
||||||
|
onClick={() => handleInboxIssueSorting("order_by", option.key)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
{inboxSorting?.order_by?.includes(option.key) && <Check className="h-3 w-3" />}
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
))}
|
||||||
|
<hr className="my-2" />
|
||||||
|
{INBOX_ISSUE_SORT_BY_OPTIONS.map((option) => (
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
key={option.key}
|
||||||
|
className="flex items-center justify-between gap-2"
|
||||||
|
onClick={() => handleInboxIssueSorting("sort_by", option.key)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
{inboxSorting?.sort_by?.includes(option.key) && <Check className="h-3 w-3" />}
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
))}
|
||||||
|
</CustomMenu>
|
||||||
|
);
|
||||||
|
});
|
@ -1,364 +0,0 @@
|
|||||||
import { FC, useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { DayPicker } from "react-day-picker";
|
|
||||||
import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle } from "lucide-react";
|
|
||||||
import { Popover } from "@headlessui/react";
|
|
||||||
// icons
|
|
||||||
import type { TInboxDetailedStatus } from "@plane/types";
|
|
||||||
// ui
|
|
||||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
|
||||||
// components
|
|
||||||
import {
|
|
||||||
AcceptIssueModal,
|
|
||||||
DeclineIssueModal,
|
|
||||||
DeleteInboxIssueModal,
|
|
||||||
SelectDuplicateInboxIssueModal,
|
|
||||||
} from "@/components/inbox";
|
|
||||||
import { ISSUE_DELETED } from "@/constants/event-tracker";
|
|
||||||
import { EUserProjectRoles } from "@/constants/project";
|
|
||||||
// hooks
|
|
||||||
import { getDate } from "@/helpers/date-time.helper";
|
|
||||||
import { useUser, useInboxIssues, useIssueDetail, useWorkspace, useEventTracker } from "@/hooks/store";
|
|
||||||
// types
|
|
||||||
//helpers
|
|
||||||
|
|
||||||
type TInboxIssueActionsHeader = {
|
|
||||||
workspaceSlug: string;
|
|
||||||
projectId: string;
|
|
||||||
inboxId: string;
|
|
||||||
inboxIssueId: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TInboxIssueOperations = {
|
|
||||||
updateInboxIssueStatus: (data: TInboxDetailedStatus) => Promise<void>;
|
|
||||||
removeInboxIssue: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((props) => {
|
|
||||||
const { workspaceSlug, projectId, inboxId, inboxIssueId } = props;
|
|
||||||
// router
|
|
||||||
const router = useRouter();
|
|
||||||
// hooks
|
|
||||||
const { captureIssueEvent } = useEventTracker();
|
|
||||||
const { currentWorkspace } = useWorkspace();
|
|
||||||
const {
|
|
||||||
issues: { getInboxIssuesByInboxId, getInboxIssueByIssueId, updateInboxIssueStatus, removeInboxIssue },
|
|
||||||
} = useInboxIssues();
|
|
||||||
const {
|
|
||||||
issue: { getIssueById },
|
|
||||||
} = useIssueDetail();
|
|
||||||
const {
|
|
||||||
currentUser,
|
|
||||||
membership: { currentProjectRole },
|
|
||||||
} = useUser();
|
|
||||||
|
|
||||||
// states
|
|
||||||
const [date, setDate] = useState(new Date());
|
|
||||||
const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false);
|
|
||||||
const [acceptIssueModal, setAcceptIssueModal] = useState(false);
|
|
||||||
const [declineIssueModal, setDeclineIssueModal] = useState(false);
|
|
||||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
|
||||||
|
|
||||||
// derived values
|
|
||||||
const inboxIssues = getInboxIssuesByInboxId(inboxId);
|
|
||||||
const issueStatus = (inboxIssueId && inboxId && getInboxIssueByIssueId(inboxId, inboxIssueId)) || undefined;
|
|
||||||
const issue = (inboxIssueId && getIssueById(inboxIssueId)) || undefined;
|
|
||||||
|
|
||||||
const currentIssueIndex = inboxIssues?.findIndex((issue) => issue === inboxIssueId) ?? 0;
|
|
||||||
|
|
||||||
const inboxIssueOperations: TInboxIssueOperations = useMemo(
|
|
||||||
() => ({
|
|
||||||
updateInboxIssueStatus: async (data: TInboxDetailedStatus) => {
|
|
||||||
try {
|
|
||||||
if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId) throw new Error("Missing required parameters");
|
|
||||||
await updateInboxIssueStatus(workspaceSlug, projectId, inboxId, inboxIssueId, data);
|
|
||||||
} catch (error) {
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
title: "Error!",
|
|
||||||
message: "Something went wrong while updating inbox status. Please try again.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
removeInboxIssue: async () => {
|
|
||||||
try {
|
|
||||||
if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId || !currentWorkspace)
|
|
||||||
throw new Error("Missing required parameters");
|
|
||||||
await removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId);
|
|
||||||
captureIssueEvent({
|
|
||||||
eventName: ISSUE_DELETED,
|
|
||||||
payload: {
|
|
||||||
id: inboxIssueId,
|
|
||||||
state: "SUCCESS",
|
|
||||||
element: "Inbox page",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
router.push({
|
|
||||||
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
title: "Error!",
|
|
||||||
message: "Something went wrong while deleting inbox issue. Please try again.",
|
|
||||||
});
|
|
||||||
captureIssueEvent({
|
|
||||||
eventName: ISSUE_DELETED,
|
|
||||||
payload: {
|
|
||||||
id: inboxIssueId,
|
|
||||||
state: "FAILED",
|
|
||||||
element: "Inbox page",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
currentWorkspace,
|
|
||||||
workspaceSlug,
|
|
||||||
projectId,
|
|
||||||
inboxId,
|
|
||||||
inboxIssueId,
|
|
||||||
updateInboxIssueStatus,
|
|
||||||
removeInboxIssue,
|
|
||||||
captureIssueEvent,
|
|
||||||
router,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleInboxIssueNavigation = useCallback(
|
|
||||||
(direction: "next" | "prev") => {
|
|
||||||
if (!inboxIssues || !inboxIssueId) return;
|
|
||||||
const activeElement = document.activeElement as HTMLElement;
|
|
||||||
if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return;
|
|
||||||
const nextIssueIndex =
|
|
||||||
direction === "next"
|
|
||||||
? (currentIssueIndex + 1) % inboxIssues.length
|
|
||||||
: (currentIssueIndex - 1 + inboxIssues.length) % inboxIssues.length;
|
|
||||||
const nextIssueId = inboxIssues[nextIssueIndex];
|
|
||||||
if (!nextIssueId) return;
|
|
||||||
router.push({
|
|
||||||
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
|
|
||||||
query: {
|
|
||||||
inboxIssueId: nextIssueId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[workspaceSlug, projectId, inboxId, inboxIssues, inboxIssueId, currentIssueIndex, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onKeyDown = useCallback(
|
|
||||||
(e: KeyboardEvent) => {
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
handleInboxIssueNavigation("prev");
|
|
||||||
} else if (e.key === "ArrowDown") {
|
|
||||||
handleInboxIssueNavigation("next");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleInboxIssueNavigation]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener("keydown", onKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", onKeyDown);
|
|
||||||
};
|
|
||||||
}, [onKeyDown]);
|
|
||||||
|
|
||||||
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
const tomorrow = getDate(today);
|
|
||||||
tomorrow?.setDate(today.getDate() + 1);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!issueStatus || !issueStatus.snoozed_till) return;
|
|
||||||
setDate(issueStatus.snoozed_till);
|
|
||||||
}, [issueStatus]);
|
|
||||||
|
|
||||||
if (!issueStatus || !issue || !inboxIssues) return <></>;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{issue && (
|
|
||||||
<>
|
|
||||||
<SelectDuplicateInboxIssueModal
|
|
||||||
isOpen={selectDuplicateIssue}
|
|
||||||
onClose={() => setSelectDuplicateIssue(false)}
|
|
||||||
value={issueStatus.duplicate_to}
|
|
||||||
onSubmit={(dupIssueId) => {
|
|
||||||
inboxIssueOperations
|
|
||||||
.updateInboxIssueStatus({
|
|
||||||
status: 2,
|
|
||||||
duplicate_to: dupIssueId,
|
|
||||||
})
|
|
||||||
.finally(() => setSelectDuplicateIssue(false));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AcceptIssueModal
|
|
||||||
data={issue}
|
|
||||||
isOpen={acceptIssueModal}
|
|
||||||
onClose={() => setAcceptIssueModal(false)}
|
|
||||||
onSubmit={async () => {
|
|
||||||
await inboxIssueOperations
|
|
||||||
.updateInboxIssueStatus({
|
|
||||||
status: 1,
|
|
||||||
})
|
|
||||||
.finally(() => setAcceptIssueModal(false));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DeclineIssueModal
|
|
||||||
data={issue}
|
|
||||||
isOpen={declineIssueModal}
|
|
||||||
onClose={() => setDeclineIssueModal(false)}
|
|
||||||
onSubmit={async () => {
|
|
||||||
await inboxIssueOperations
|
|
||||||
.updateInboxIssueStatus({
|
|
||||||
status: -1,
|
|
||||||
})
|
|
||||||
.finally(() => setDeclineIssueModal(false));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DeleteInboxIssueModal
|
|
||||||
data={issue}
|
|
||||||
isOpen={deleteIssueModal}
|
|
||||||
onClose={() => setDeleteIssueModal(false)}
|
|
||||||
onSubmit={async () => {
|
|
||||||
await inboxIssueOperations.removeInboxIssue().finally(() => setDeclineIssueModal(false));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{inboxIssueId && (
|
|
||||||
<div className="relative flex h-full w-full items-center justify-between gap-2 px-4">
|
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-custom-border-200 bg-custom-background-90 p-1.5 hover:bg-custom-background-80"
|
|
||||||
onClick={() => handleInboxIssueNavigation("prev")}
|
|
||||||
>
|
|
||||||
<ChevronUp size={14} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-custom-border-200 bg-custom-background-90 p-1.5 hover:bg-custom-background-80"
|
|
||||||
onClick={() => handleInboxIssueNavigation("next")}
|
|
||||||
>
|
|
||||||
<ChevronDown size={14} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
<div className="text-sm">
|
|
||||||
{currentIssueIndex + 1}/{inboxIssues?.length ?? 0}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
{isAllowed && (issueStatus.status === 0 || issueStatus.status === -2) && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Popover className="relative">
|
|
||||||
<Popover.Button as="button" type="button">
|
|
||||||
<Button variant="neutral-primary" prependIcon={<Clock size={14} strokeWidth={2} />} size="sm">
|
|
||||||
Snooze
|
|
||||||
</Button>
|
|
||||||
</Popover.Button>
|
|
||||||
<Popover.Panel className="absolute right-0 z-10 mt-2 w-80 rounded-md bg-custom-background-100 p-2 shadow-lg">
|
|
||||||
{({ close }) => (
|
|
||||||
<div className="flex h-full w-full flex-col gap-y-1">
|
|
||||||
<DayPicker
|
|
||||||
selected={getDate(date)}
|
|
||||||
defaultMonth={getDate(date)}
|
|
||||||
onSelect={(date) => {
|
|
||||||
if (!date) return;
|
|
||||||
setDate(date);
|
|
||||||
}}
|
|
||||||
mode="single"
|
|
||||||
className="border border-custom-border-200 rounded-md p-3"
|
|
||||||
disabled={
|
|
||||||
tomorrow
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
before: tomorrow,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => {
|
|
||||||
close();
|
|
||||||
inboxIssueOperations.updateInboxIssueStatus({
|
|
||||||
status: 0,
|
|
||||||
snoozed_till: date,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Snooze
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Popover.Panel>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAllowed && issueStatus.status === -2 && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="neutral-primary"
|
|
||||||
size="sm"
|
|
||||||
prependIcon={<FileStack size={14} strokeWidth={2} />}
|
|
||||||
onClick={() => setSelectDuplicateIssue(true)}
|
|
||||||
>
|
|
||||||
Mark as duplicate
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAllowed && (issueStatus.status === 0 || issueStatus.status === -2) && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="neutral-primary"
|
|
||||||
size="sm"
|
|
||||||
prependIcon={<CheckCircle2 className="text-green-500" size={14} strokeWidth={2} />}
|
|
||||||
onClick={() => setAcceptIssueModal(true)}
|
|
||||||
>
|
|
||||||
Accept
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAllowed && issueStatus.status === -2 && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="neutral-primary"
|
|
||||||
size="sm"
|
|
||||||
prependIcon={<XCircle className="text-red-500" size={14} strokeWidth={2} />}
|
|
||||||
onClick={() => setDeclineIssueModal(true)}
|
|
||||||
>
|
|
||||||
Decline
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(isAllowed || currentUser?.id === issue?.created_by) && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="neutral-primary"
|
|
||||||
size="sm"
|
|
||||||
prependIcon={<Trash2 className="text-red-500" size={14} strokeWidth={2} />}
|
|
||||||
onClick={() => setDeleteIssueModal(true)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,56 +1,45 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// hooks
|
|
||||||
import { INBOX_STATUS } from "@/constants/inbox";
|
|
||||||
import { useInboxIssues } from "@/hooks/store";
|
|
||||||
// constants
|
// constants
|
||||||
|
import { INBOX_STATUS } from "@/constants/inbox";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
// store
|
||||||
|
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspaceSlug: string;
|
inboxIssue: IInboxIssueStore;
|
||||||
projectId: string;
|
|
||||||
inboxId: string;
|
|
||||||
issueId: string;
|
|
||||||
iconSize?: number;
|
iconSize?: number;
|
||||||
showDescription?: boolean;
|
showDescription?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InboxIssueStatus: React.FC<Props> = observer((props) => {
|
export const InboxIssueStatus: React.FC<Props> = observer((props) => {
|
||||||
const { workspaceSlug, projectId, inboxId, issueId, iconSize = 18, showDescription = false } = props;
|
const { inboxIssue, iconSize = 16, showDescription = false } = props;
|
||||||
// hooks
|
// derived values
|
||||||
const {
|
const inboxIssueStatusDetail = INBOX_STATUS.find((s) => s.status === inboxIssue.status);
|
||||||
issues: { getInboxIssueByIssueId },
|
|
||||||
} = useInboxIssues();
|
|
||||||
|
|
||||||
const inboxIssueDetail = getInboxIssueByIssueId(inboxId, issueId);
|
|
||||||
if (!inboxIssueDetail) return <></>;
|
|
||||||
|
|
||||||
const inboxIssueStatusDetail = INBOX_STATUS.find((s) => s.status === inboxIssueDetail.status);
|
|
||||||
if (!inboxIssueStatusDetail) return <></>;
|
if (!inboxIssueStatusDetail) return <></>;
|
||||||
|
|
||||||
const isSnoozedDatePassed =
|
const isSnoozedDatePassed = inboxIssue.status === 0 && new Date(inboxIssue.snoozed_till ?? "") < new Date();
|
||||||
inboxIssueDetail.status === 0 && !!inboxIssueDetail.snoozed_till && inboxIssueDetail.snoozed_till < new Date();
|
|
||||||
|
const description = inboxIssueStatusDetail.description(new Date(inboxIssue.snoozed_till ?? ""));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center ${inboxIssueStatusDetail.textColor(isSnoozedDatePassed)} ${
|
className={cn(
|
||||||
showDescription
|
`relative flex flex-col gap-1 p-1.5 py-0.5 rounded ${inboxIssueStatusDetail.textColor(
|
||||||
? `p-3 gap-2 text-sm rounded-md border ${inboxIssueStatusDetail.bgColor(
|
|
||||||
isSnoozedDatePassed
|
isSnoozedDatePassed
|
||||||
)} ${inboxIssueStatusDetail.borderColor(isSnoozedDatePassed)} `
|
)} ${inboxIssueStatusDetail.bgColor(isSnoozedDatePassed)}`
|
||||||
: "w-full justify-end gap-1 text-xs"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<inboxIssueStatusDetail.icon size={iconSize} strokeWidth={2} />
|
|
||||||
{showDescription ? (
|
|
||||||
inboxIssueStatusDetail.description(
|
|
||||||
workspaceSlug,
|
|
||||||
projectId,
|
|
||||||
inboxIssueDetail.duplicate_to ?? "",
|
|
||||||
inboxIssueDetail.snoozed_till
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span>{inboxIssueStatusDetail.title}</span>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center gap-1`}>
|
||||||
|
<inboxIssueStatusDetail.icon size={iconSize} />
|
||||||
|
<div className="font-medium text-xs">
|
||||||
|
{inboxIssue?.status === 0 && inboxIssue?.snoozed_till
|
||||||
|
? inboxIssueStatusDetail.description(inboxIssue?.snoozed_till)
|
||||||
|
: inboxIssueStatusDetail.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showDescription && <div className="text-sm">{description}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,14 +1,6 @@
|
|||||||
|
export * from "./root";
|
||||||
export * from "./modals";
|
export * from "./modals";
|
||||||
|
export * from "./sidebar";
|
||||||
export * from "./inbox-issue-actions";
|
export * from "./inbox-filter";
|
||||||
|
export * from "./content";
|
||||||
export * from "./inbox-issue-status";
|
export * from "./inbox-issue-status";
|
||||||
|
|
||||||
export * from "./content/root";
|
|
||||||
|
|
||||||
export * from "./sidebar/root";
|
|
||||||
|
|
||||||
export * from "./sidebar/filter/filter-selection";
|
|
||||||
export * from "./sidebar/filter/applied-filters";
|
|
||||||
|
|
||||||
export * from "./sidebar/inbox-list";
|
|
||||||
export * from "./sidebar/inbox-list-item";
|
|
||||||
|
@ -5,11 +5,11 @@ import type { TIssue } from "@plane/types";
|
|||||||
// icons
|
// icons
|
||||||
// ui
|
// ui
|
||||||
import { Button } from "@plane/ui";
|
import { Button } from "@plane/ui";
|
||||||
// types
|
// hooks
|
||||||
import { useProject } from "@/hooks/store";
|
import { useProject } from "@/hooks/store";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: TIssue;
|
data: Partial<TIssue>;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: () => Promise<void>;
|
onSubmit: () => Promise<void>;
|
||||||
@ -70,7 +70,8 @@ export const AcceptIssueModal: React.FC<Props> = ({ isOpen, onClose, data, onSub
|
|||||||
<p className="text-sm text-custom-text-200">
|
<p className="text-sm text-custom-text-200">
|
||||||
Are you sure you want to accept issue{" "}
|
Are you sure you want to accept issue{" "}
|
||||||
<span className="break-all font-medium text-custom-text-100">
|
<span className="break-all font-medium text-custom-text-100">
|
||||||
{getProjectById(data?.project_id)?.identifier}-{data?.sequence_id}
|
{(data && data?.project_id && getProjectById(data?.project_id)?.identifier) || ""}-
|
||||||
|
{data?.sequence_id}
|
||||||
</span>
|
</span>
|
||||||
{""}? Once accepted, this issue will be added to the project issues list.
|
{""}? Once accepted, this issue will be added to the project issues list.
|
||||||
</p>
|
</p>
|
||||||
|
@ -1,24 +1,24 @@
|
|||||||
import { Fragment, useRef, useState } from "react";
|
import { Fragment, useRef, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { Sparkle } from "lucide-react";
|
import { Sparkle } from "lucide-react";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Transition, Dialog } from "@headlessui/react";
|
||||||
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||||
|
// types
|
||||||
import { TIssue } from "@plane/types";
|
import { TIssue } from "@plane/types";
|
||||||
// hooks
|
// ui
|
||||||
import { Button, Input, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
|
import { Button, Input, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
|
// components
|
||||||
import { GptAssistantPopover } from "@/components/core";
|
import { GptAssistantPopover } from "@/components/core";
|
||||||
import { PriorityDropdown } from "@/components/dropdowns";
|
import { PriorityDropdown } from "@/components/dropdowns";
|
||||||
|
// constants
|
||||||
import { ISSUE_CREATED } from "@/constants/event-tracker";
|
import { ISSUE_CREATED } from "@/constants/event-tracker";
|
||||||
import { useApplication, useEventTracker, useWorkspace, useInboxIssues, useMention } from "@/hooks/store";
|
// hooks
|
||||||
|
import { useApplication, useEventTracker, useWorkspace, useMention, useProjectInbox } from "@/hooks/store";
|
||||||
// services
|
// services
|
||||||
import { AIService } from "@/services/ai.service";
|
import { AIService } from "@/services/ai.service";
|
||||||
import { FileService } from "@/services/file.service";
|
import { FileService } from "@/services/file.service";
|
||||||
// components
|
|
||||||
// ui
|
|
||||||
// types
|
|
||||||
// constants
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -26,10 +26,8 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const defaultValues: Partial<TIssue> = {
|
const defaultValues: Partial<TIssue> = {
|
||||||
project_id: "",
|
|
||||||
name: "",
|
name: "",
|
||||||
description_html: "<p></p>",
|
description_html: "<p></p>",
|
||||||
parent_id: null,
|
|
||||||
priority: "none",
|
priority: "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -39,33 +37,27 @@ const fileService = new FileService();
|
|||||||
|
|
||||||
export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
|
export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
|
||||||
const { isOpen, onClose } = props;
|
const { isOpen, onClose } = props;
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
if (!workspaceSlug || !projectId) return null;
|
||||||
// states
|
// states
|
||||||
const [createMore, setCreateMore] = useState(false);
|
const [createMore, setCreateMore] = useState(false);
|
||||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
||||||
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
||||||
// refs
|
// refs
|
||||||
const editorRef = useRef<any>(null);
|
const editorRef = useRef<any>(null);
|
||||||
// router
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug, projectId, inboxId } = router.query as {
|
|
||||||
workspaceSlug: string;
|
|
||||||
projectId: string;
|
|
||||||
inboxId: string;
|
|
||||||
};
|
|
||||||
// hooks
|
// hooks
|
||||||
const { mentionHighlights, mentionSuggestions } = useMention();
|
const { mentionHighlights, mentionSuggestions } = useMention();
|
||||||
const workspaceStore = useWorkspace();
|
const workspaceStore = useWorkspace();
|
||||||
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
|
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
|
||||||
|
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const { createInboxIssue } = useProjectInbox();
|
||||||
issues: { createInboxIssue },
|
|
||||||
} = useInboxIssues();
|
|
||||||
const {
|
const {
|
||||||
config: { envConfig },
|
config: { envConfig },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
const { captureIssueEvent } = useEventTracker();
|
const { captureIssueEvent } = useEventTracker();
|
||||||
|
// form info
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
@ -73,24 +65,26 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
|
|||||||
reset,
|
reset,
|
||||||
watch,
|
watch,
|
||||||
getValues,
|
getValues,
|
||||||
} = useForm({ defaultValues });
|
} = useForm<Partial<TIssue>>({ defaultValues });
|
||||||
|
const issueName = watch("name");
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onClose();
|
onClose();
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
|
editorRef?.current?.clearEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
const issueName = watch("name");
|
|
||||||
|
|
||||||
const handleFormSubmit = async (formData: Partial<TIssue>) => {
|
const handleFormSubmit = async (formData: Partial<TIssue>) => {
|
||||||
if (!workspaceSlug || !projectId || !inboxId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
await createInboxIssue(workspaceSlug.toString(), projectId.toString(), formData)
|
||||||
await createInboxIssue(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), formData)
|
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!createMore) {
|
if (!createMore) {
|
||||||
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${res.id}`);
|
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/?inboxIssueId=${res?.issue?.id}`);
|
||||||
handleClose();
|
handleClose();
|
||||||
} else reset(defaultValues);
|
} else {
|
||||||
|
reset(defaultValues);
|
||||||
|
editorRef?.current?.clearEditor();
|
||||||
|
}
|
||||||
captureIssueEvent({
|
captureIssueEvent({
|
||||||
eventName: ISSUE_CREATED,
|
eventName: ISSUE_CREATED,
|
||||||
payload: {
|
payload: {
|
||||||
@ -117,11 +111,11 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
const handleAiAssistance = async (response: string) => {
|
const handleAiAssistance = async (response: string) => {
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
editorRef.current?.setEditorValueAtCursorPosition(response);
|
editorRef.current?.setEditorValueAtCursorPosition(response);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAutoGenerateDescription = async () => {
|
const handleAutoGenerateDescription = async () => {
|
||||||
|
const issueName = getValues("name");
|
||||||
if (!workspaceSlug || !projectId || !issueName) return;
|
if (!workspaceSlug || !projectId || !issueName) return;
|
||||||
|
|
||||||
setIAmFeelingLucky(true);
|
setIAmFeelingLucky(true);
|
||||||
@ -220,7 +214,7 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="border-0.5 absolute bottom-3.5 right-3.5 z-10 flex rounded bg-custom-background-80">
|
<div className="border-0.5 absolute bottom-3.5 right-3.5 z-10 flex rounded bg-custom-background-80">
|
||||||
{issueName && issueName !== "" && (
|
{watch("name") && issueName !== "" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${
|
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${
|
||||||
@ -242,7 +236,7 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
|
|||||||
{envConfig?.has_openai_configured && (
|
{envConfig?.has_openai_configured && (
|
||||||
<GptAssistantPopover
|
<GptAssistantPopover
|
||||||
isOpen={gptAssistantModal}
|
isOpen={gptAssistantModal}
|
||||||
projectId={projectId}
|
projectId={projectId.toString()}
|
||||||
handleClose={() => {
|
handleClose={() => {
|
||||||
setGptAssistantModal((prevData) => !prevData);
|
setGptAssistantModal((prevData) => !prevData);
|
||||||
// this is done so that the title do not reset after gpt popover closed
|
// this is done so that the title do not reset after gpt popover closed
|
||||||
|
@ -9,7 +9,7 @@ import { Button } from "@plane/ui";
|
|||||||
import { useProject } from "@/hooks/store";
|
import { useProject } from "@/hooks/store";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: TIssue;
|
data: Partial<TIssue>;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: () => Promise<void>;
|
onSubmit: () => Promise<void>;
|
||||||
@ -70,7 +70,8 @@ export const DeclineIssueModal: React.FC<Props> = ({ isOpen, onClose, data, onSu
|
|||||||
<p className="text-sm text-custom-text-200">
|
<p className="text-sm text-custom-text-200">
|
||||||
Are you sure you want to decline issue{" "}
|
Are you sure you want to decline issue{" "}
|
||||||
<span className="break-words font-medium text-custom-text-100">
|
<span className="break-words font-medium text-custom-text-100">
|
||||||
{getProjectById(data?.project_id)?.identifier}-{data?.sequence_id}
|
{(data && data?.project_id && getProjectById(data?.project_id)?.identifier) || ""}-
|
||||||
|
{data?.sequence_id}
|
||||||
</span>
|
</span>
|
||||||
{""}? This action cannot be undone.
|
{""}? This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
// hooks
|
// hooks
|
||||||
@ -11,7 +11,7 @@ import { useProject } from "@/hooks/store";
|
|||||||
// types
|
// types
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: TIssue;
|
data: Partial<TIssue>;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: () => Promise<void>;
|
onSubmit: () => Promise<void>;
|
||||||
@ -30,7 +30,7 @@ export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClos
|
|||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
onSubmit().finally(() => setIsDeleting(false));
|
onSubmit().finally(() => handleClose());
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -73,7 +73,8 @@ export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClos
|
|||||||
<p className="text-sm text-custom-text-200">
|
<p className="text-sm text-custom-text-200">
|
||||||
Are you sure you want to delete issue{" "}
|
Are you sure you want to delete issue{" "}
|
||||||
<span className="break-words font-medium text-custom-text-100">
|
<span className="break-words font-medium text-custom-text-100">
|
||||||
{getProjectById(data?.project_id)?.identifier}-{data?.sequence_id}
|
{(data && data?.project_id && getProjectById(data?.project_id)?.identifier) || ""}-
|
||||||
|
{data?.sequence_id}
|
||||||
</span>
|
</span>
|
||||||
{""}? The issue will only be deleted from the inbox and this action cannot be undone.
|
{""}? The issue will only be deleted from the inbox and this action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
|
@ -3,3 +3,4 @@ export * from "./create-issue-modal";
|
|||||||
export * from "./decline-issue-modal";
|
export * from "./decline-issue-modal";
|
||||||
export * from "./delete-issue-modal";
|
export * from "./delete-issue-modal";
|
||||||
export * from "./select-duplicate";
|
export * from "./select-duplicate";
|
||||||
|
export * from "./snooze-issue-modal";
|
||||||
|
78
web/components/inbox/modals/snooze-issue-modal.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { FC, Fragment, useState } from "react";
|
||||||
|
import { DayPicker } from "react-day-picker";
|
||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
// ui
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
|
|
||||||
|
export type InboxIssueSnoozeModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
value: Date | undefined;
|
||||||
|
onConfirm: (value: Date) => void;
|
||||||
|
handleClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InboxIssueSnoozeModal: FC<InboxIssueSnoozeModalProps> = (props) => {
|
||||||
|
const { isOpen, handleClose, value, onConfirm } = props;
|
||||||
|
// states
|
||||||
|
const [date, setDate] = useState(value || new Date());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||||
|
<Transition.Child
|
||||||
|
as={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-custom-backdrop transition-opacity" />
|
||||||
|
</Transition.Child>
|
||||||
|
<div className="fixed inset-0 z-20 flex w-full justify-center overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||||
|
<Transition.Child
|
||||||
|
as={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 flex transform rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||||
|
<div className="flex h-full w-full flex-col gap-y-1">
|
||||||
|
<DayPicker
|
||||||
|
selected={date ? new Date(date) : undefined}
|
||||||
|
defaultMonth={date ? new Date(date) : undefined}
|
||||||
|
onSelect={(date) => {
|
||||||
|
if (!date) return;
|
||||||
|
setDate(date);
|
||||||
|
}}
|
||||||
|
mode="single"
|
||||||
|
className="rounded-md border border-custom-border-200 p-3"
|
||||||
|
// disabled={[
|
||||||
|
// {
|
||||||
|
// before: tomorrow,
|
||||||
|
// },
|
||||||
|
// ]}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
onConfirm(date);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Snooze
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
};
|
68
web/components/inbox/root.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { Inbox } from "lucide-react";
|
||||||
|
// components
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { InboxSidebar, InboxContentRoot } from "@/components/inbox";
|
||||||
|
import { InboxLayoutLoader } from "@/components/ui";
|
||||||
|
// constants
|
||||||
|
import { EmptyStateType } from "@/constants/empty-state";
|
||||||
|
// hooks
|
||||||
|
import { useProjectInbox } from "@/hooks/store";
|
||||||
|
|
||||||
|
type TInboxIssueRoot = {
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
inboxIssueId: string | undefined;
|
||||||
|
inboxAccessible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InboxIssueRoot: FC<TInboxIssueRoot> = observer((props) => {
|
||||||
|
const { workspaceSlug, projectId, inboxIssueId, inboxAccessible } = props;
|
||||||
|
// hooks
|
||||||
|
const { isLoading, error, fetchInboxIssues } = useProjectInbox();
|
||||||
|
|
||||||
|
useSWR(
|
||||||
|
inboxAccessible && workspaceSlug && projectId ? `PROJECT_INBOX_ISSUES_${workspaceSlug}_${projectId}` : null,
|
||||||
|
() => {
|
||||||
|
inboxAccessible && workspaceSlug && projectId && fetchInboxIssues(workspaceSlug.toString(), projectId.toString());
|
||||||
|
},
|
||||||
|
{ revalidateOnFocus: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// loader
|
||||||
|
if (isLoading === "init-loading")
|
||||||
|
return (
|
||||||
|
<div className="relative flex w-full h-full flex-col">
|
||||||
|
<InboxLayoutLoader />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// error
|
||||||
|
if (error && error?.status === "init-error")
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full flex flex-col gap-3 justify-center items-center">
|
||||||
|
<Inbox size={60} strokeWidth={1.5} />
|
||||||
|
<div className="text-custom-text-200">{error?.message}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full flex overflow-hidden">
|
||||||
|
<InboxSidebar workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||||
|
|
||||||
|
{inboxIssueId ? (
|
||||||
|
<InboxContentRoot
|
||||||
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
|
projectId={projectId.toString()}
|
||||||
|
inboxIssueId={inboxIssueId.toString()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full relative flex justify-center items-center">
|
||||||
|
<EmptyState type={EmptyStateType.INBOX_DETAIL_EMPTY_STATE} layout="screen-simple" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
@ -1,171 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
// mobx store
|
|
||||||
// icons
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { TInboxIssueFilterOptions, TIssuePriorities } from "@plane/types";
|
|
||||||
import { PriorityIcon } from "@plane/ui";
|
|
||||||
// helpers
|
|
||||||
import { INBOX_STATUS } from "@/constants/inbox";
|
|
||||||
import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper";
|
|
||||||
// types
|
|
||||||
import { useInboxIssues } from "@/hooks/store";
|
|
||||||
// constants
|
|
||||||
|
|
||||||
type TInboxIssueAppliedFilter = { workspaceSlug: string; projectId: string; inboxId: string };
|
|
||||||
|
|
||||||
export const IssueStatusLabel = ({ status }: { status: number }) => {
|
|
||||||
const issueStatusDetail = INBOX_STATUS.find((s) => s.status === status);
|
|
||||||
|
|
||||||
if (!issueStatusDetail) return <></>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex items-center gap-1">
|
|
||||||
<div className={issueStatusDetail.textColor(false)}>
|
|
||||||
<issueStatusDetail.icon size={12} />
|
|
||||||
</div>
|
|
||||||
<div>{issueStatusDetail.title}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InboxIssueAppliedFilter: FC<TInboxIssueAppliedFilter> = observer((props) => {
|
|
||||||
const { workspaceSlug, projectId, inboxId } = props;
|
|
||||||
// hooks
|
|
||||||
const {
|
|
||||||
filters: { inboxFilters, updateInboxFilters },
|
|
||||||
} = useInboxIssues();
|
|
||||||
|
|
||||||
const filters = inboxFilters?.filters;
|
|
||||||
|
|
||||||
const handleUpdateFilter = (filter: Partial<TInboxIssueFilterOptions>) => {
|
|
||||||
if (!workspaceSlug || !projectId || !inboxId) return;
|
|
||||||
updateInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), filter);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearAllFilters = () => {
|
|
||||||
const newFilters: TInboxIssueFilterOptions = { priority: [], inbox_status: [] };
|
|
||||||
updateInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), newFilters);
|
|
||||||
};
|
|
||||||
|
|
||||||
let filtersLength = 0;
|
|
||||||
Object.keys(filters ?? {}).forEach((key) => {
|
|
||||||
const filterKey = key as keyof TInboxIssueFilterOptions;
|
|
||||||
if (filters?.[filterKey] && Array.isArray(filters[filterKey])) filtersLength += (filters[filterKey] ?? []).length;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!filters || filtersLength <= 0) return <></>;
|
|
||||||
return (
|
|
||||||
<div className="relative flex flex-wrap items-center gap-2 p-3 text-[0.65rem] border-b border-custom-border-100">
|
|
||||||
{Object.keys(filters).map((key) => {
|
|
||||||
const filterKey = key as keyof TInboxIssueFilterOptions;
|
|
||||||
|
|
||||||
if (filters[filterKey].length > 0)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex items-center gap-x-2 rounded-full border border-custom-border-200 bg-custom-background-80 px-2 py-1"
|
|
||||||
>
|
|
||||||
<span className="capitalize text-custom-text-200">{replaceUnderscoreIfSnakeCase(key)}:</span>
|
|
||||||
{filters[filterKey]?.length < 0 ? (
|
|
||||||
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
|
|
||||||
) : (
|
|
||||||
<div className="space-x-2">
|
|
||||||
{filterKey === "priority" ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{filters.priority?.map((priority) => (
|
|
||||||
<div
|
|
||||||
key={priority}
|
|
||||||
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize ${
|
|
||||||
priority === "urgent"
|
|
||||||
? "bg-red-500/20 text-red-500"
|
|
||||||
: priority === "high"
|
|
||||||
? "bg-orange-500/20 text-orange-500"
|
|
||||||
: priority === "medium"
|
|
||||||
? "bg-yellow-500/20 text-yellow-500"
|
|
||||||
: priority === "low"
|
|
||||||
? "bg-green-500/20 text-green-500"
|
|
||||||
: "bg-custom-background-90 text-custom-text-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="relative flex items-center gap-1">
|
|
||||||
<div>
|
|
||||||
<PriorityIcon priority={priority as TIssuePriorities} size={14} />
|
|
||||||
</div>
|
|
||||||
<div>{priority}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() =>
|
|
||||||
handleUpdateFilter({
|
|
||||||
priority: filters.priority?.filter((p) => p !== priority),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
handleUpdateFilter({
|
|
||||||
priority: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : filterKey === "inbox_status" ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{filters.inbox_status?.map((status) => (
|
|
||||||
<div
|
|
||||||
key={status}
|
|
||||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-2 py-0.5 capitalize text-custom-text-200"
|
|
||||||
>
|
|
||||||
<IssueStatusLabel status={status} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() =>
|
|
||||||
handleUpdateFilter({
|
|
||||||
inbox_status: filters.inbox_status?.filter((p) => p !== status),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
handleUpdateFilter({
|
|
||||||
inbox_status: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
(filters[filterKey] as any)?.join(", ")
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClearAllFilters}
|
|
||||||
className="flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-80 px-3 py-1.5 text-custom-text-200 hover:text-custom-text-100"
|
|
||||||
>
|
|
||||||
<span>Clear all</span>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,117 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { TInboxIssueFilterOptions } from "@plane/types";
|
|
||||||
// mobx store
|
|
||||||
// ui
|
|
||||||
// icons
|
|
||||||
import { PriorityIcon } from "@plane/ui";
|
|
||||||
import { MultiLevelDropdown } from "@/components/ui";
|
|
||||||
// types
|
|
||||||
// constants
|
|
||||||
import { INBOX_STATUS } from "@/constants/inbox";
|
|
||||||
import { ISSUE_PRIORITIES } from "@/constants/issue";
|
|
||||||
import { useInboxIssues } from "@/hooks/store";
|
|
||||||
|
|
||||||
type TInboxIssueFilterSelection = { workspaceSlug: string; projectId: string; inboxId: string };
|
|
||||||
|
|
||||||
export const InboxIssueFilterSelection: FC<TInboxIssueFilterSelection> = observer((props) => {
|
|
||||||
const { workspaceSlug, projectId, inboxId } = props;
|
|
||||||
// router
|
|
||||||
const router = useRouter();
|
|
||||||
const { inboxIssueId } = router.query;
|
|
||||||
// hooks
|
|
||||||
const {
|
|
||||||
filters: { inboxFilters, updateInboxFilters },
|
|
||||||
} = useInboxIssues();
|
|
||||||
|
|
||||||
const filters = inboxFilters?.filters;
|
|
||||||
|
|
||||||
let filtersLength = 0;
|
|
||||||
Object.keys(filters ?? {}).forEach((key) => {
|
|
||||||
const filterKey = key as keyof TInboxIssueFilterOptions;
|
|
||||||
if (filters?.[filterKey] && Array.isArray(filters[filterKey])) filtersLength += (filters[filterKey] ?? []).length;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<MultiLevelDropdown
|
|
||||||
label="Filters"
|
|
||||||
onSelect={(option) => {
|
|
||||||
if (!workspaceSlug || !projectId || !inboxId) return;
|
|
||||||
|
|
||||||
const key = option.key as keyof TInboxIssueFilterOptions;
|
|
||||||
const currentValue: any[] = filters?.[key] ?? [];
|
|
||||||
|
|
||||||
const valueExists = currentValue.includes(option.value);
|
|
||||||
|
|
||||||
if (valueExists)
|
|
||||||
updateInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), {
|
|
||||||
[option.key]: currentValue.filter((val) => val !== option.value),
|
|
||||||
});
|
|
||||||
else
|
|
||||||
updateInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), {
|
|
||||||
[option.key]: [...currentValue, option.value],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (inboxIssueId) {
|
|
||||||
router.push({
|
|
||||||
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
direction="right"
|
|
||||||
height="rg"
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
id: "priority",
|
|
||||||
label: "Priority",
|
|
||||||
value: ISSUE_PRIORITIES.map((p) => p.key),
|
|
||||||
hasChildren: true,
|
|
||||||
children: ISSUE_PRIORITIES.map((priority) => ({
|
|
||||||
id: priority.key,
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center gap-2 capitalize">
|
|
||||||
<PriorityIcon priority={priority.key} /> {priority.title ?? "None"}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
value: {
|
|
||||||
key: "priority",
|
|
||||||
value: priority.key,
|
|
||||||
},
|
|
||||||
selected: filters?.priority?.includes(priority.key),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "inbox_status",
|
|
||||||
label: "Status",
|
|
||||||
value: INBOX_STATUS.map((status) => status.status),
|
|
||||||
hasChildren: true,
|
|
||||||
children: INBOX_STATUS.map((status) => ({
|
|
||||||
id: status.status.toString(),
|
|
||||||
label: (
|
|
||||||
<div className="relative inline-flex gap-2 items-center">
|
|
||||||
<div className={status.textColor(false)}>
|
|
||||||
<status.icon size={12} />
|
|
||||||
</div>
|
|
||||||
<div>{status.title}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
value: {
|
|
||||||
key: "inbox_status",
|
|
||||||
value: status.status,
|
|
||||||
},
|
|
||||||
selected: filters?.inbox_status?.includes(status.status),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{filtersLength > 0 && (
|
|
||||||
<div className="absolute -right-2 -top-2 z-10 grid h-4 w-4 place-items-center rounded-full border border-custom-border-200 bg-custom-background-80 text-[0.65rem] text-custom-text-100">
|
|
||||||
<span>{filtersLength}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,49 +1,40 @@
|
|||||||
import { FC, useEffect } from "react";
|
import { FC, MouseEvent, useEffect } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// icons
|
|
||||||
import { CalendarDays } from "lucide-react";
|
|
||||||
// hooks
|
|
||||||
// ui
|
|
||||||
import { Tooltip, PriorityIcon } from "@plane/ui";
|
import { Tooltip, PriorityIcon } from "@plane/ui";
|
||||||
// helpers
|
|
||||||
import { InboxIssueStatus } from "@/components/inbox/inbox-issue-status";
|
|
||||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
|
||||||
// components
|
// components
|
||||||
import { useInboxIssues, useIssueDetail, useProject } from "@/hooks/store";
|
import { InboxIssueStatus } from "@/components/inbox";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||||
|
// hooks
|
||||||
|
import { useLabel } from "@/hooks/store";
|
||||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||||
|
// store
|
||||||
|
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||||
|
|
||||||
type TInboxIssueListItem = {
|
type InboxIssueListItemProps = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
inboxId: string;
|
projectIdentifier?: string;
|
||||||
issueId: string;
|
inboxIssue: IInboxIssueStore;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InboxIssueListItem: FC<TInboxIssueListItem> = observer((props) => {
|
export const InboxIssueListItem: FC<InboxIssueListItemProps> = observer((props) => {
|
||||||
const { workspaceSlug, projectId, inboxId, issueId } = props;
|
const { workspaceSlug, projectId, inboxIssue, projectIdentifier } = props;
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { inboxIssueId } = router.query;
|
const { inboxIssueId } = router.query;
|
||||||
// hooks
|
// store
|
||||||
const { getProjectById } = useProject();
|
const { projectLabels } = useLabel();
|
||||||
const {
|
|
||||||
issues: { getInboxIssueByIssueId },
|
|
||||||
} = useInboxIssues();
|
|
||||||
const {
|
|
||||||
issue: { getIssueById },
|
|
||||||
} = useIssueDetail();
|
|
||||||
const { isMobile } = usePlatformOS();
|
const { isMobile } = usePlatformOS();
|
||||||
const inboxIssueDetail = getInboxIssueByIssueId(inboxId, issueId);
|
const issue = inboxIssue.issue;
|
||||||
const issue = getIssueById(issueId);
|
|
||||||
|
|
||||||
if (!issue || !inboxIssueDetail) return <></>;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (issueId === inboxIssueId) {
|
if (issue.id === inboxIssueId) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const issueItemCard = document.getElementById(`inbox-issue-list-item-${issueId}`);
|
const issueItemCard = document.getElementById(`inbox-issue-list-item-${issue.id}`);
|
||||||
if (issueItemCard)
|
if (issueItemCard)
|
||||||
issueItemCard.scrollIntoView({
|
issueItemCard.scrollIntoView({
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
@ -51,52 +42,81 @@ export const InboxIssueListItem: FC<TInboxIssueListItem> = observer((props) => {
|
|||||||
});
|
});
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
}, [issueId, inboxIssueId]);
|
}, [inboxIssueId, issue.id]);
|
||||||
|
|
||||||
|
const handleIssueRedirection = (event: MouseEvent, currentIssueId: string | undefined) => {
|
||||||
|
if (inboxIssueId === currentIssueId) event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!issue) return <></>;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
id={`inbox-issue-list-item-${issue.id}`}
|
id={`inbox-issue-list-item-${issue.id}`}
|
||||||
key={`${inboxId}_${issueId}`}
|
key={`${projectId}_${issue.id}`}
|
||||||
href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${issueId}`}
|
href={`/${workspaceSlug}/projects/${projectId}/inbox?inboxIssueId=${issue.id}`}
|
||||||
|
onClick={(e) => handleIssueRedirection(e, issue.id)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`relative min-h-[5rem]select-none space-y-3 border-b border-custom-border-200 px-4 py-2 hover:bg-custom-primary/5 cursor-pointer ${
|
className={cn(
|
||||||
inboxIssueId === issueId ? "bg-custom-primary/5" : " "
|
`flex flex-col gap-2 relative border border-t-transparent border-l-transparent border-r-transparent border-b-custom-border-200 p-4 hover:bg-custom-primary/5 cursor-pointer transition-all`,
|
||||||
} ${inboxIssueDetail.status !== -2 ? "opacity-60" : ""}`}
|
{ "bg-custom-primary/5 border-custom-primary-100 border": inboxIssueId === issue.id }
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-x-2">
|
<div className="space-y-1">
|
||||||
<div className="relative flex items-center gap-x-2 overflow-hidden">
|
<div className="relative flex items-center justify-between gap-2">
|
||||||
<p className="flex-shrink-0 text-xs text-custom-text-200">
|
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
||||||
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
|
{projectIdentifier}-{issue.sequence_id}
|
||||||
</p>
|
|
||||||
<h5 className="truncate text-sm">{issue.name}</h5>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{inboxIssue.status !== -2 && <InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />}
|
||||||
<InboxIssueStatus
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
inboxId={inboxId}
|
|
||||||
issueId={issueId}
|
|
||||||
iconSize={14}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="truncate w-full text-sm">{issue.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`} isMobile={isMobile}>
|
|
||||||
<PriorityIcon priority={issue.priority ?? null} className="h-3.5 w-3.5" />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
tooltipHeading="Created on"
|
tooltipHeading="Created on"
|
||||||
tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`}
|
tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 rounded border border-custom-border-200 px-2 py-[0.19rem] text-xs text-custom-text-200 shadow-sm">
|
<div className="text-xs text-custom-text-200">{renderFormattedDate(issue.created_at ?? "")}</div>
|
||||||
<CalendarDays size={12} strokeWidth={1.5} />
|
|
||||||
<span>{renderFormattedDate(issue.created_at ?? "")}</span>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className="border-2 rounded-full border-custom-border-400" />
|
||||||
|
|
||||||
|
{issue.priority && (
|
||||||
|
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}>
|
||||||
|
<PriorityIcon priority={issue.priority} withContainer className="w-3 h-3" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{issue.label_ids && issue.label_ids.length > 3 ? (
|
||||||
|
<div className="relative !h-[17.5px] flex items-center gap-1 rounded border border-custom-border-300 px-1 text-xs">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-orange-400" />
|
||||||
|
<span className="normal-case max-w-28 truncate">{`${issue.label_ids.length} labels`}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(issue.label_ids ?? []).map((labelId) => {
|
||||||
|
const labelDetails = projectLabels?.find((l) => l.id === labelId);
|
||||||
|
if (!labelDetails) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={labelId}
|
||||||
|
className="relative !h-[17.5px] flex items-center gap-1 rounded border border-custom-border-300 px-1 text-xs"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: labelDetails.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="normal-case max-w-28 truncate">{labelDetails.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -1,33 +1,33 @@
|
|||||||
import { FC } from "react";
|
import { FC, Fragment } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// hooks
|
|
||||||
import { useInboxIssues } from "@/hooks/store";
|
|
||||||
// components
|
// components
|
||||||
import { InboxIssueListItem } from "../";
|
import { InboxIssueListItem } from "@/components/inbox";
|
||||||
|
// store
|
||||||
|
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||||
|
|
||||||
type TInboxIssueList = { workspaceSlug: string; projectId: string; inboxId: string };
|
export type InboxIssueListProps = {
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
projectIdentifier?: string;
|
||||||
|
inboxIssues: IInboxIssueStore[];
|
||||||
|
};
|
||||||
|
|
||||||
export const InboxIssueList: FC<TInboxIssueList> = observer((props) => {
|
export const InboxIssueList: FC<InboxIssueListProps> = observer((props) => {
|
||||||
const { workspaceSlug, projectId, inboxId } = props;
|
const { workspaceSlug, projectId, projectIdentifier, inboxIssues } = props;
|
||||||
// hooks
|
|
||||||
const {
|
|
||||||
issues: { getInboxIssuesByInboxId },
|
|
||||||
} = useInboxIssues();
|
|
||||||
|
|
||||||
const inboxIssueIds = getInboxIssuesByInboxId(inboxId);
|
|
||||||
|
|
||||||
if (!inboxIssueIds) return <></>;
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-y-auto w-full h-full vertical-scrollbar scrollbar-md">
|
<>
|
||||||
{inboxIssueIds.map((issueId) => (
|
{inboxIssues.map((inboxIssue) => (
|
||||||
|
<Fragment key={inboxIssue.id}>
|
||||||
<InboxIssueListItem
|
<InboxIssueListItem
|
||||||
key={issueId}
|
key={inboxIssue.id}
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
inboxId={inboxId}
|
projectIdentifier={projectIdentifier}
|
||||||
issueId={issueId}
|
inboxIssue={inboxIssue}
|
||||||
/>
|
/>
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
3
web/components/inbox/sidebar/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./root";
|
||||||
|
export * from "./inbox-list";
|
||||||
|
export * from "./inbox-list-item";
|
@ -1,49 +1,143 @@
|
|||||||
import { FC } from "react";
|
import { FC, useCallback, useRef } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Inbox } from "lucide-react";
|
import { useRouter } from "next/router";
|
||||||
// hooks
|
import { TInboxIssueCurrentTab } from "@plane/types";
|
||||||
import { InboxSidebarLoader } from "@/components/ui";
|
import { Loader } from "@plane/ui";
|
||||||
import { useInboxIssues } from "@/hooks/store";
|
|
||||||
// ui
|
|
||||||
// components
|
// components
|
||||||
import { InboxIssueList, InboxIssueFilterSelection, InboxIssueAppliedFilter } from "../";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { FiltersRoot, InboxIssueAppliedFilters, InboxIssueList } from "@/components/inbox";
|
||||||
|
import { InboxSidebarLoader } from "@/components/ui";
|
||||||
|
// constants
|
||||||
|
import { EmptyStateType } from "@/constants/empty-state";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
// hooks
|
||||||
|
import { useProject, useProjectInbox } from "@/hooks/store";
|
||||||
|
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||||
|
|
||||||
type TInboxSidebarRoot = {
|
type IInboxSidebarProps = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
inboxId: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InboxSidebarRoot: FC<TInboxSidebarRoot> = observer((props) => {
|
const tabNavigationOptions: { key: TInboxIssueCurrentTab; label: string }[] = [
|
||||||
const { workspaceSlug, projectId, inboxId } = props;
|
{
|
||||||
// store hooks
|
key: "open",
|
||||||
const {
|
label: "Open",
|
||||||
issues: { loader },
|
},
|
||||||
} = useInboxIssues();
|
{
|
||||||
|
key: "closed",
|
||||||
|
label: "Closed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (loader === "init-loader") {
|
export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
|
||||||
return <InboxSidebarLoader />;
|
const { workspaceSlug, projectId } = props;
|
||||||
}
|
// ref
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
|
// store
|
||||||
|
const { currentProjectDetails } = useProject();
|
||||||
|
const {
|
||||||
|
currentTab,
|
||||||
|
handleCurrentTab,
|
||||||
|
isLoading,
|
||||||
|
inboxIssuesArray,
|
||||||
|
inboxIssuePaginationInfo,
|
||||||
|
fetchInboxPaginationIssues,
|
||||||
|
getAppliedFiltersCount,
|
||||||
|
} = useProjectInbox();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const fetchNextPages = useCallback(() => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
fetchInboxPaginationIssues(workspaceSlug.toString(), projectId.toString());
|
||||||
|
}, [workspaceSlug, projectId, fetchInboxPaginationIssues]);
|
||||||
|
// page observer
|
||||||
|
useIntersectionObserver({
|
||||||
|
containerRef,
|
||||||
|
elementRef,
|
||||||
|
callback: fetchNextPages,
|
||||||
|
rootMargin: "20%",
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col w-full h-full">
|
<div className="flex-shrink-0 w-2/6 h-full border-r border-custom-border-300">
|
||||||
<div className="flex-shrink-0 w-full h-[50px] relative flex justify-between items-center gap-2 p-2 px-3 border-b border-custom-border-300">
|
<div className="relative w-full h-full flex flex-col overflow-hidden">
|
||||||
<div className="relative flex items-center gap-1">
|
<div className="border-b border-custom-border-300 flex-shrink-0 w-full h-[50px] relative flex items-center gap-2 pr-3 whitespace-nowrap">
|
||||||
<div className="relative w-6 h-6 flex justify-center items-center rounded bg-custom-background-80">
|
{tabNavigationOptions.map((option) => (
|
||||||
<Inbox className="w-4 h-4" />
|
<div
|
||||||
|
key={option?.key}
|
||||||
|
className={cn(
|
||||||
|
`text-sm relative flex items-center gap-1 h-[50px] px-2 cursor-pointer transition-all font-medium`,
|
||||||
|
currentTab === option?.key ? `text-custom-primary-100` : `hover:text-custom-text-200`
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (currentTab != option?.key) handleCurrentTab(option?.key);
|
||||||
|
router.push(`/${workspaceSlug}/projects/${projectId}/inbox`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>{option?.label}</div>
|
||||||
|
{option?.key === "open" && currentTab === option?.key && (
|
||||||
|
<div className="rounded-full p-1.5 py-0.5 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold">
|
||||||
|
{inboxIssuePaginationInfo?.total_results || 0}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
`border absolute bottom-0 right-0 left-0 rounded-t-md`,
|
||||||
|
currentTab === option?.key ? `border-custom-primary-100` : `border-transparent`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="z-20">
|
))}
|
||||||
<InboxIssueFilterSelection workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} />
|
<div className="ml-auto">
|
||||||
|
<FiltersRoot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full h-auto">
|
<InboxIssueAppliedFilters />
|
||||||
<InboxIssueAppliedFilter workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full h-full overflow-hidden">
|
{isLoading && !inboxIssuePaginationInfo?.next_page_results ? (
|
||||||
<InboxIssueList workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} />
|
<InboxSidebarLoader />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-full h-full overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-md"
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
{inboxIssuesArray.length > 0 ? (
|
||||||
|
<InboxIssueList
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
projectIdentifier={currentProjectDetails?.identifier}
|
||||||
|
inboxIssues={inboxIssuesArray}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full w-full">
|
||||||
|
<EmptyState
|
||||||
|
type={
|
||||||
|
getAppliedFiltersCount > 0
|
||||||
|
? EmptyStateType.INBOX_SIDEBAR_FILTER_EMPTY_STATE
|
||||||
|
: currentTab === "open"
|
||||||
|
? EmptyStateType.INBOX_SIDEBAR_OPEN_TAB
|
||||||
|
: EmptyStateType.INBOX_SIDEBAR_CLOSED_TAB
|
||||||
|
}
|
||||||
|
layout="screen-simple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={elementRef}>
|
||||||
|
{inboxIssuePaginationInfo?.next_page_results && (
|
||||||
|
<Loader className="mx-auto w-full space-y-4 py-4 px-2">
|
||||||
|
<Loader.Item height="64px" width="w-100" />
|
||||||
|
<Loader.Item height="64px" width="w-100" />
|
||||||
|
</Loader>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -3,7 +3,8 @@ export * from "./issue-modal";
|
|||||||
export * from "./delete-issue-modal";
|
export * from "./delete-issue-modal";
|
||||||
export * from "./description-form";
|
export * from "./description-form";
|
||||||
export * from "./issue-layouts";
|
export * from "./issue-layouts";
|
||||||
|
export * from "./description-input";
|
||||||
|
export * from "./title-input";
|
||||||
export * from "./parent-issues-list-modal";
|
export * from "./parent-issues-list-modal";
|
||||||
export * from "./label";
|
export * from "./label";
|
||||||
export * from "./confirm-issue-discard";
|
export * from "./confirm-issue-discard";
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
export * from "./root";
|
|
||||||
export * from "./main-content";
|
|
||||||
export * from "./sidebar";
|
|
@ -1,119 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
// hooks
|
|
||||||
import { StateGroupIcon } from "@plane/ui";
|
|
||||||
import { IssueUpdateStatus, TIssueOperations } from "@/components/issues";
|
|
||||||
import { useIssueDetail, useProjectState, useUser } from "@/hooks/store";
|
|
||||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
|
||||||
// components
|
|
||||||
import { InboxIssueStatus } from "../../../inbox/inbox-issue-status";
|
|
||||||
import { IssueDescriptionInput } from "../../description-input";
|
|
||||||
import { IssueTitleInput } from "../../title-input";
|
|
||||||
import { IssueActivity } from "../issue-activity";
|
|
||||||
import { IssueReaction } from "../reactions";
|
|
||||||
// ui
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
workspaceSlug: string;
|
|
||||||
projectId: string;
|
|
||||||
inboxId: string;
|
|
||||||
issueId: string;
|
|
||||||
issueOperations: TIssueOperations;
|
|
||||||
is_editable: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
|
||||||
const { workspaceSlug, projectId, inboxId, issueId, issueOperations, is_editable } = props;
|
|
||||||
// states
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
|
||||||
// hooks
|
|
||||||
const { currentUser } = useUser();
|
|
||||||
const { projectStates } = useProjectState();
|
|
||||||
const {
|
|
||||||
issue: { getIssueById },
|
|
||||||
} = useIssueDetail();
|
|
||||||
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSubmitting === "submitted") {
|
|
||||||
setShowAlert(false);
|
|
||||||
setTimeout(async () => {
|
|
||||||
setIsSubmitting("saved");
|
|
||||||
}, 3000);
|
|
||||||
} else if (isSubmitting === "submitting") {
|
|
||||||
setShowAlert(true);
|
|
||||||
}
|
|
||||||
}, [isSubmitting, setShowAlert, setIsSubmitting]);
|
|
||||||
|
|
||||||
const issue = issueId ? getIssueById(issueId) : undefined;
|
|
||||||
if (!issue) return <></>;
|
|
||||||
|
|
||||||
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
|
|
||||||
|
|
||||||
const issueDescription =
|
|
||||||
issue.description_html !== undefined || issue.description_html !== null
|
|
||||||
? issue.description_html != ""
|
|
||||||
? issue.description_html
|
|
||||||
: "<p></p>"
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="rounded-lg space-y-4">
|
|
||||||
<InboxIssueStatus
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
inboxId={inboxId}
|
|
||||||
issueId={issueId}
|
|
||||||
showDescription
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mb-2.5 flex items-center">
|
|
||||||
{currentIssueState && (
|
|
||||||
<StateGroupIcon
|
|
||||||
className="mr-3 h-4 w-4"
|
|
||||||
stateGroup={currentIssueState.group}
|
|
||||||
color={currentIssueState.color}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issue} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<IssueTitleInput
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={issue.project_id}
|
|
||||||
issueId={issue.id}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
|
||||||
issueOperations={issueOperations}
|
|
||||||
disabled={!is_editable}
|
|
||||||
value={issue.name}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<IssueDescriptionInput
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={issue.project_id}
|
|
||||||
issueId={issue.id}
|
|
||||||
value={issueDescription}
|
|
||||||
initialValue={issueDescription}
|
|
||||||
disabled={!is_editable}
|
|
||||||
issueOperations={issueOperations}
|
|
||||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{currentUser && (
|
|
||||||
<IssueReaction
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
issueId={issueId}
|
|
||||||
currentUser={currentUser}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pb-12">
|
|
||||||
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,152 +0,0 @@
|
|||||||
import { FC, useMemo } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { TIssue } from "@plane/types";
|
|
||||||
// components
|
|
||||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
|
||||||
import { EUserProjectRoles } from "@/constants/project";
|
|
||||||
import { useEventTracker, useInboxIssues, useIssueDetail, useUser } from "@/hooks/store";
|
|
||||||
// ui
|
|
||||||
// types
|
|
||||||
import { TIssueOperations } from "../root";
|
|
||||||
import { InboxIssueMainContent } from "./main-content";
|
|
||||||
import { InboxIssueDetailsSidebar } from "./sidebar";
|
|
||||||
// constants
|
|
||||||
|
|
||||||
export type TInboxIssueDetailRoot = {
|
|
||||||
workspaceSlug: string;
|
|
||||||
projectId: string;
|
|
||||||
inboxId: string;
|
|
||||||
issueId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
|
|
||||||
const { workspaceSlug, projectId, inboxId, issueId } = props;
|
|
||||||
// router
|
|
||||||
const router = useRouter();
|
|
||||||
// hooks
|
|
||||||
const {
|
|
||||||
issues: { fetchInboxIssueById, updateInboxIssue, removeInboxIssue },
|
|
||||||
} = useInboxIssues();
|
|
||||||
const {
|
|
||||||
issue: { getIssueById },
|
|
||||||
fetchActivities,
|
|
||||||
fetchComments,
|
|
||||||
} = useIssueDetail();
|
|
||||||
const { captureIssueEvent } = useEventTracker();
|
|
||||||
const {
|
|
||||||
membership: { currentProjectRole },
|
|
||||||
} = useUser();
|
|
||||||
|
|
||||||
const issueOperations: TIssueOperations = useMemo(
|
|
||||||
() => ({
|
|
||||||
fetch: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
|
||||||
try {
|
|
||||||
await fetchInboxIssueById(workspaceSlug, projectId, inboxId, issueId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching the parent issue");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
|
||||||
try {
|
|
||||||
await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data);
|
|
||||||
captureIssueEvent({
|
|
||||||
eventName: "Inbox issue updated",
|
|
||||||
payload: { ...data, state: "SUCCESS", element: "Inbox" },
|
|
||||||
updates: {
|
|
||||||
changed_property: Object.keys(data).join(","),
|
|
||||||
change_details: Object.values(data).join(","),
|
|
||||||
},
|
|
||||||
path: router.asPath,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setToast({
|
|
||||||
title: "Issue update failed",
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
message: "Issue update failed",
|
|
||||||
});
|
|
||||||
captureIssueEvent({
|
|
||||||
eventName: "Inbox issue updated",
|
|
||||||
payload: { state: "SUCCESS", element: "Inbox" },
|
|
||||||
updates: {
|
|
||||||
changed_property: Object.keys(data).join(","),
|
|
||||||
change_details: Object.values(data).join(","),
|
|
||||||
},
|
|
||||||
path: router.asPath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
|
||||||
try {
|
|
||||||
await removeInboxIssue(workspaceSlug, projectId, inboxId, issueId);
|
|
||||||
setToast({
|
|
||||||
title: "Issue deleted successfully",
|
|
||||||
type: TOAST_TYPE.SUCCESS,
|
|
||||||
message: "Issue deleted successfully",
|
|
||||||
});
|
|
||||||
captureIssueEvent({
|
|
||||||
eventName: "Inbox issue deleted",
|
|
||||||
payload: { id: issueId, state: "SUCCESS", element: "Inbox" },
|
|
||||||
path: router.asPath,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
captureIssueEvent({
|
|
||||||
eventName: "Inbox issue deleted",
|
|
||||||
payload: { id: issueId, state: "FAILED", element: "Inbox" },
|
|
||||||
path: router.asPath,
|
|
||||||
});
|
|
||||||
setToast({
|
|
||||||
title: "Issue delete failed",
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
message: "Issue delete failed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[inboxId, fetchInboxIssueById, updateInboxIssue, removeInboxIssue]
|
|
||||||
);
|
|
||||||
|
|
||||||
useSWR(
|
|
||||||
workspaceSlug && projectId && inboxId && issueId
|
|
||||||
? `INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxId}_${issueId}`
|
|
||||||
: null,
|
|
||||||
async () => {
|
|
||||||
if (workspaceSlug && projectId && inboxId && issueId) {
|
|
||||||
await issueOperations.fetch(workspaceSlug, projectId, issueId);
|
|
||||||
await fetchActivities(workspaceSlug, projectId, issueId);
|
|
||||||
await fetchComments(workspaceSlug, projectId, issueId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// checking if issue is editable, based on user role
|
|
||||||
const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
|
||||||
|
|
||||||
// issue details
|
|
||||||
const issue = getIssueById(issueId);
|
|
||||||
|
|
||||||
if (!issue) return <></>;
|
|
||||||
return (
|
|
||||||
<div className="flex h-full overflow-hidden">
|
|
||||||
<div className="h-full w-2/3 space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5 vertical-scrollbar scrollbar-md">
|
|
||||||
<InboxIssueMainContent
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
inboxId={inboxId}
|
|
||||||
issueId={issueId}
|
|
||||||
issueOperations={issueOperations}
|
|
||||||
is_editable={is_editable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="h-full w-1/3 space-y-5 overflow-hidden border-l border-custom-border-300 py-5">
|
|
||||||
<InboxIssueDetailsSidebar
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
issueId={issueId}
|
|
||||||
issueOperations={issueOperations}
|
|
||||||
is_editable={is_editable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,14 +1,14 @@
|
|||||||
export * from "./root";
|
export * from "./root";
|
||||||
|
|
||||||
export * from "./main-content";
|
export * from "./main-content";
|
||||||
export * from "./sidebar";
|
export * from "./sidebar";
|
||||||
|
|
||||||
// select
|
|
||||||
export * from "./cycle-select";
|
|
||||||
export * from "./module-select";
|
|
||||||
export * from "./parent-select";
|
|
||||||
export * from "./relation-select";
|
|
||||||
export * from "./parent";
|
export * from "./parent";
|
||||||
export * from "./label";
|
export * from "./label";
|
||||||
export * from "./subscription";
|
export * from "./subscription";
|
||||||
export * from "./links";
|
export * from "./links";
|
||||||
|
export * from "./issue-activity";
|
||||||
|
export * from "./reactions";
|
||||||
|
// select components
|
||||||
|
export * from "./cycle-select";
|
||||||
|
export * from "./module-select";
|
||||||
|
export * from "./parent-select";
|
||||||
|
export * from "./relation-select";
|
||||||
|
@ -7,7 +7,6 @@ import { Popover } from "@headlessui/react";
|
|||||||
import { IIssueLabel } from "@plane/types";
|
import { IIssueLabel } from "@plane/types";
|
||||||
// hooks
|
// hooks
|
||||||
import { Input, TOAST_TYPE, setToast } from "@plane/ui";
|
import { Input, TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
import { useIssueDetail } from "@/hooks/store";
|
|
||||||
// ui
|
// ui
|
||||||
// types
|
// types
|
||||||
import { TLabelOperations } from "./root";
|
import { TLabelOperations } from "./root";
|
||||||
@ -16,6 +15,7 @@ type ILabelCreate = {
|
|||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
|
values: string[];
|
||||||
labelOperations: TLabelOperations;
|
labelOperations: TLabelOperations;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
@ -26,11 +26,7 @@ const defaultValues: Partial<IIssueLabel> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const LabelCreate: FC<ILabelCreate> = (props) => {
|
export const LabelCreate: FC<ILabelCreate> = (props) => {
|
||||||
const { workspaceSlug, projectId, issueId, labelOperations, disabled = false } = props;
|
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled = false } = props;
|
||||||
// hooks
|
|
||||||
const {
|
|
||||||
issue: { getIssueById },
|
|
||||||
} = useIssueDetail();
|
|
||||||
// state
|
// state
|
||||||
const [isCreateToggle, setIsCreateToggle] = useState(false);
|
const [isCreateToggle, setIsCreateToggle] = useState(false);
|
||||||
const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle);
|
const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle);
|
||||||
@ -70,9 +66,8 @@ export const LabelCreate: FC<ILabelCreate> = (props) => {
|
|||||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const issue = getIssueById(issueId);
|
|
||||||
const labelResponse = await labelOperations.createLabel(workspaceSlug, projectId, formData);
|
const labelResponse = await labelOperations.createLabel(workspaceSlug, projectId, formData);
|
||||||
const currentLabels = [...(issue?.label_ids || []), labelResponse.id];
|
const currentLabels = [...(values || []), labelResponse.id];
|
||||||
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
|
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
|
||||||
reset(defaultValues);
|
reset(defaultValues);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
// types
|
// types
|
||||||
import { useIssueDetail, useLabel } from "@/hooks/store";
|
import { useLabel } from "@/hooks/store";
|
||||||
import { TLabelOperations } from "./root";
|
import { TLabelOperations } from "./root";
|
||||||
|
|
||||||
type TLabelListItem = {
|
type TLabelListItem = {
|
||||||
@ -9,24 +9,21 @@ type TLabelListItem = {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
labelId: string;
|
labelId: string;
|
||||||
|
values: string[];
|
||||||
labelOperations: TLabelOperations;
|
labelOperations: TLabelOperations;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LabelListItem: FC<TLabelListItem> = (props) => {
|
export const LabelListItem: FC<TLabelListItem> = (props) => {
|
||||||
const { workspaceSlug, projectId, issueId, labelId, labelOperations, disabled } = props;
|
const { workspaceSlug, projectId, issueId, labelId, values, labelOperations, disabled } = props;
|
||||||
// hooks
|
// hooks
|
||||||
const {
|
|
||||||
issue: { getIssueById },
|
|
||||||
} = useIssueDetail();
|
|
||||||
const { getLabelById } = useLabel();
|
const { getLabelById } = useLabel();
|
||||||
|
|
||||||
const issue = getIssueById(issueId);
|
|
||||||
const label = getLabelById(labelId);
|
const label = getLabelById(labelId);
|
||||||
|
|
||||||
const handleLabel = async () => {
|
const handleLabel = async () => {
|
||||||
if (issue && !disabled) {
|
if (values && !disabled) {
|
||||||
const currentLabels = issue.label_ids.filter((_labelId) => _labelId !== labelId);
|
const currentLabels = values.filter((_labelId) => _labelId !== labelId);
|
||||||
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
|
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// components
|
// components
|
||||||
import { useIssueDetail } from "@/hooks/store";
|
|
||||||
import { LabelListItem } from "./label-list-item";
|
import { LabelListItem } from "./label-list-item";
|
||||||
// hooks
|
|
||||||
// types
|
// types
|
||||||
import { TLabelOperations } from "./root";
|
import { TLabelOperations } from "./root";
|
||||||
|
|
||||||
@ -11,21 +9,16 @@ type TLabelList = {
|
|||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
|
values: string[];
|
||||||
labelOperations: TLabelOperations;
|
labelOperations: TLabelOperations;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LabelList: FC<TLabelList> = observer((props) => {
|
export const LabelList: FC<TLabelList> = observer((props) => {
|
||||||
const { workspaceSlug, projectId, issueId, labelOperations, disabled } = props;
|
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled } = props;
|
||||||
// hooks
|
const issueLabels = values || undefined;
|
||||||
const {
|
|
||||||
issue: { getIssueById },
|
|
||||||
} = useIssueDetail();
|
|
||||||
|
|
||||||
const issue = getIssueById(issueId);
|
if (!issueId || !issueLabels) return <></>;
|
||||||
const issueLabels = issue?.label_ids || undefined;
|
|
||||||
|
|
||||||
if (!issue || !issueLabels) return <></>;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{issueLabels.map((labelId) => (
|
{issueLabels.map((labelId) => (
|
||||||
@ -35,6 +28,7 @@ export const LabelList: FC<TLabelList> = observer((props) => {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
issueId={issueId}
|
issueId={issueId}
|
||||||
labelId={labelId}
|
labelId={labelId}
|
||||||
|
values={issueLabels}
|
||||||
labelOperations={labelOperations}
|
labelOperations={labelOperations}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
@ -4,7 +4,7 @@ import { IIssueLabel, TIssue } from "@plane/types";
|
|||||||
// components
|
// components
|
||||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail, useLabel } from "@/hooks/store";
|
import { useIssueDetail, useLabel, useProjectInbox } from "@/hooks/store";
|
||||||
// ui
|
// ui
|
||||||
// types
|
// types
|
||||||
import { LabelList, LabelCreate, IssueLabelSelectRoot } from "./";
|
import { LabelList, LabelCreate, IssueLabelSelectRoot } from "./";
|
||||||
@ -28,6 +28,12 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
|
|||||||
// hooks
|
// hooks
|
||||||
const { updateIssue } = useIssueDetail();
|
const { updateIssue } = useIssueDetail();
|
||||||
const { createLabel } = useLabel();
|
const { createLabel } = useLabel();
|
||||||
|
const {
|
||||||
|
issue: { getIssueById },
|
||||||
|
} = useIssueDetail();
|
||||||
|
const { getIssueInboxByIssueId } = useProjectInbox();
|
||||||
|
|
||||||
|
const issue = isInboxIssue ? getIssueInboxByIssueId(issueId)?.issue : getIssueById(issueId);
|
||||||
|
|
||||||
const labelOperations: TLabelOperations = useMemo(
|
const labelOperations: TLabelOperations = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -72,6 +78,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
|
|||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
issueId={issueId}
|
issueId={issueId}
|
||||||
|
values={issue?.label_ids || []}
|
||||||
labelOperations={labelOperations}
|
labelOperations={labelOperations}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
@ -81,6 +88,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
|
|||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
issueId={issueId}
|
issueId={issueId}
|
||||||
|
values={issue?.label_ids || []}
|
||||||
labelOperations={labelOperations}
|
labelOperations={labelOperations}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -90,6 +98,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
|
|||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
issueId={issueId}
|
issueId={issueId}
|
||||||
|
values={issue?.label_ids || []}
|
||||||
labelOperations={labelOperations}
|
labelOperations={labelOperations}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -4,22 +4,20 @@ import { usePopper } from "react-popper";
|
|||||||
import { Check, Search, Tag } from "lucide-react";
|
import { Check, Search, Tag } from "lucide-react";
|
||||||
import { Combobox } from "@headlessui/react";
|
import { Combobox } from "@headlessui/react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail, useLabel } from "@/hooks/store";
|
import { useLabel } from "@/hooks/store";
|
||||||
// components
|
// components
|
||||||
|
|
||||||
export interface IIssueLabelSelect {
|
export interface IIssueLabelSelect {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
|
values: string[];
|
||||||
onSelect: (_labelIds: string[]) => void;
|
onSelect: (_labelIds: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) => {
|
export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) => {
|
||||||
const { workspaceSlug, projectId, issueId, onSelect } = props;
|
const { workspaceSlug, projectId, issueId, values, onSelect } = props;
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
|
||||||
issue: { getIssueById },
|
|
||||||
} = useIssueDetail();
|
|
||||||
const { fetchProjectLabels, getProjectLabels } = useLabel();
|
const { fetchProjectLabels, getProjectLabels } = useLabel();
|
||||||
// states
|
// states
|
||||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
@ -27,7 +25,6 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
|
|||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const issue = getIssueById(issueId);
|
|
||||||
const projectLabels = getProjectLabels(projectId);
|
const projectLabels = getProjectLabels(projectId);
|
||||||
|
|
||||||
const fetchLabels = () => {
|
const fetchLabels = () => {
|
||||||
@ -67,7 +64,7 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const issueLabels = issue?.label_ids ?? [];
|
const issueLabels = values ?? [];
|
||||||
|
|
||||||
const label = (
|
const label = (
|
||||||
<div
|
<div
|
||||||
@ -87,7 +84,7 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!issue) return <></>;
|
if (!issueId || !values) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -8,17 +8,24 @@ type TIssueLabelSelectRoot = {
|
|||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
|
values: string[];
|
||||||
labelOperations: TLabelOperations;
|
labelOperations: TLabelOperations;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IssueLabelSelectRoot: FC<TIssueLabelSelectRoot> = (props) => {
|
export const IssueLabelSelectRoot: FC<TIssueLabelSelectRoot> = (props) => {
|
||||||
const { workspaceSlug, projectId, issueId, labelOperations } = props;
|
const { workspaceSlug, projectId, issueId, values, labelOperations } = props;
|
||||||
|
|
||||||
const handleLabel = async (_labelIds: string[]) => {
|
const handleLabel = async (_labelIds: string[]) => {
|
||||||
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: _labelIds });
|
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: _labelIds });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IssueLabelSelect workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} onSelect={handleLabel} />
|
<IssueLabelSelect
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
projectId={projectId}
|
||||||
|
issueId={issueId}
|
||||||
|
values={values}
|
||||||
|
onSelect={handleLabel}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -32,9 +32,8 @@ import {
|
|||||||
import { LeaveProjectModal, ProjectLogo, PublishProjectModal } from "@/components/project";
|
import { LeaveProjectModal, ProjectLogo, PublishProjectModal } from "@/components/project";
|
||||||
import { EUserProjectRoles } from "@/constants/project";
|
import { EUserProjectRoles } from "@/constants/project";
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { getNumberCount } from "@/helpers/string.helper";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useApplication, useEventTracker, useInbox, useProject } from "@/hooks/store";
|
import { useApplication, useEventTracker, useProject } from "@/hooks/store";
|
||||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||||
// helpers
|
// helpers
|
||||||
@ -95,7 +94,6 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
|
|||||||
const { theme: themeStore } = useApplication();
|
const { theme: themeStore } = useApplication();
|
||||||
const { setTrackElement } = useEventTracker();
|
const { setTrackElement } = useEventTracker();
|
||||||
const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject();
|
const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject();
|
||||||
const { getInboxesByProjectId, getInboxById } = useInbox();
|
|
||||||
const { isMobile } = usePlatformOS();
|
const { isMobile } = usePlatformOS();
|
||||||
// states
|
// states
|
||||||
const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false);
|
const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false);
|
||||||
@ -109,8 +107,6 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
|
|||||||
// derived values
|
// derived values
|
||||||
const project = getProjectById(projectId);
|
const project = getProjectById(projectId);
|
||||||
const isCollapsed = themeStore.sidebarCollapsed;
|
const isCollapsed = themeStore.sidebarCollapsed;
|
||||||
const inboxesMap = project?.inbox_view ? getInboxesByProjectId(projectId) : undefined;
|
|
||||||
const inboxDetails = inboxesMap && inboxesMap.length > 0 ? getInboxById(inboxesMap[0]) : undefined;
|
|
||||||
// auth
|
// auth
|
||||||
const isAdmin = project?.member_role === EUserProjectRoles.ADMIN;
|
const isAdmin = project?.member_role === EUserProjectRoles.ADMIN;
|
||||||
const isViewerOrGuest =
|
const isViewerOrGuest =
|
||||||
@ -375,36 +371,8 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
|
|||||||
: "text-custom-sidebar-text-300 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
: "text-custom-sidebar-text-300 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||||
} ${isCollapsed ? "justify-center" : ""}`}
|
} ${isCollapsed ? "justify-center" : ""}`}
|
||||||
>
|
>
|
||||||
{item.name === "Inbox" && inboxDetails ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-center relative">
|
|
||||||
{inboxDetails?.pending_issue_count > 0 && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute -right-1.5 -top-1 px-0.5 h-3.5 w-3.5 flex items-center tracking-tight justify-center rounded-full text-[0.5rem] border-[0.5px] border-custom-sidebar-border-200 bg-custom-background-80 text-custom-text-100",
|
|
||||||
{
|
|
||||||
"text-[0.375rem] leading-5": inboxDetails?.pending_issue_count >= 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"border-none bg-custom-primary-300 text-white": router.asPath.includes(
|
|
||||||
item.href
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{getNumberCount(inboxDetails?.pending_issue_count)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<item.Icon className="h-4 w-4 stroke-[1.5]" />
|
|
||||||
</div>
|
|
||||||
{!isCollapsed && item.name}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<item.Icon className="h-4 w-4 stroke-[1.5]" />
|
<item.Icon className="h-4 w-4 stroke-[1.5]" />
|
||||||
{!isCollapsed && item.name}
|
{!isCollapsed && item.name}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
|
@ -4,22 +4,19 @@ import { Loader } from "@plane/ui";
|
|||||||
import { InboxSidebarLoader } from "./inbox-sidebar-loader";
|
import { InboxSidebarLoader } from "./inbox-sidebar-loader";
|
||||||
|
|
||||||
export const InboxLayoutLoader = () => (
|
export const InboxLayoutLoader = () => (
|
||||||
<div className="relative flex h-full overflow-hidden">
|
<div className="relative w-full h-full flex overflow-hidden">
|
||||||
|
<div className="flex-shrink-0 w-2/6 h-full border-r border-custom-border-300">
|
||||||
<InboxSidebarLoader />
|
<InboxSidebarLoader />
|
||||||
<div className="w-full">
|
</div>
|
||||||
<Loader className="flex h-full gap-5 p-5">
|
<div className="w-4/6">
|
||||||
<div className="basis-2/3 space-y-2">
|
<Loader className="flex flex-col h-full gap-5 p-5">
|
||||||
|
<div className="space-y-2">
|
||||||
<Loader.Item height="30px" width="40%" />
|
<Loader.Item height="30px" width="40%" />
|
||||||
<Loader.Item height="15px" width="60%" />
|
<Loader.Item height="15px" width="60%" />
|
||||||
<Loader.Item height="15px" width="60%" />
|
<Loader.Item height="15px" width="60%" />
|
||||||
<Loader.Item height="15px" width="40%" />
|
<Loader.Item height="15px" width="40%" />
|
||||||
</div>
|
</div>
|
||||||
<div className="basis-1/3 space-y-3">
|
<Loader.Item height="150px" />
|
||||||
<Loader.Item height="30px" />
|
|
||||||
<Loader.Item height="30px" />
|
|
||||||
<Loader.Item height="30px" />
|
|
||||||
<Loader.Item height="30px" />
|
|
||||||
</div>
|
|
||||||
</Loader>
|
</Loader>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,24 +1,20 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export const InboxSidebarLoader = () => (
|
export const InboxSidebarLoader = () => (
|
||||||
<div className="h-full w-[340px] border-r border-custom-border-300">
|
|
||||||
<div className="flex-shrink-0 w-full h-[50px] relative flex justify-between items-center gap-2 p-2 px-3 border-b border-custom-border-300">
|
|
||||||
<span className="h-6 w-16 bg-custom-background-80 rounded" />
|
|
||||||
<span className="h-6 w-16 bg-custom-background-80 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{[...Array(6)].map((i) => (
|
{[...Array(6)].map((i, index) => (
|
||||||
<div key={i} className="flex flex-col gap-3 h-[5rem]space-y-3 border-b border-custom-border-200 px-4 py-2">
|
<div key={index} className="flex flex-col gap-2.5 h-[105px] space-y-3 border-b border-custom-border-200 p-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="h-5 w-20 bg-custom-background-80 rounded" />
|
|
||||||
<span className="h-5 w-16 bg-custom-background-80 rounded" />
|
<span className="h-5 w-16 bg-custom-background-80 rounded" />
|
||||||
|
<span className="h-5 w-36 bg-custom-background-80 rounded" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<span className="h-5 w-5 bg-custom-background-80 rounded" />
|
<span className="h-4 w-20 bg-custom-background-80 rounded" />
|
||||||
<span className="h-5 w-16 bg-custom-background-80 rounded" />
|
<span className="h-2 w-2 bg-custom-background-80 rounded-full" />
|
||||||
|
<span className="h-4 w-16 bg-custom-background-80 rounded" />
|
||||||
|
<span className="h-4 w-16 bg-custom-background-80 rounded" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
@ -90,6 +90,13 @@ export enum EmptyStateType {
|
|||||||
ACTIVE_CYCLE_PRIORITY_ISSUE_EMPTY_STATE = "active-cycle-priority-issue-empty-state",
|
ACTIVE_CYCLE_PRIORITY_ISSUE_EMPTY_STATE = "active-cycle-priority-issue-empty-state",
|
||||||
ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE = "active-cycle-assignee-empty-state",
|
ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE = "active-cycle-assignee-empty-state",
|
||||||
ACTIVE_CYCLE_LABEL_EMPTY_STATE = "active-cycle-label-empty-state",
|
ACTIVE_CYCLE_LABEL_EMPTY_STATE = "active-cycle-label-empty-state",
|
||||||
|
|
||||||
|
DISABLED_PROJECT_INBOX = "disabled-project-inbox",
|
||||||
|
|
||||||
|
INBOX_SIDEBAR_OPEN_TAB = "inbox-sidebar-open-tab",
|
||||||
|
INBOX_SIDEBAR_CLOSED_TAB = "inbox-sidebar-closed-tab",
|
||||||
|
INBOX_SIDEBAR_FILTER_EMPTY_STATE = "inbox-sidebar-filter-empty-state",
|
||||||
|
INBOX_DETAIL_EMPTY_STATE = "inbox-detail-empty-state",
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyStateDetails = {
|
const emptyStateDetails = {
|
||||||
@ -615,6 +622,41 @@ const emptyStateDetails = {
|
|||||||
title: "Add labels to issues to see the \n breakdown of work by labels.",
|
title: "Add labels to issues to see the \n breakdown of work by labels.",
|
||||||
path: "/empty-state/active-cycle/label",
|
path: "/empty-state/active-cycle/label",
|
||||||
},
|
},
|
||||||
|
[EmptyStateType.DISABLED_PROJECT_INBOX]: {
|
||||||
|
key: EmptyStateType.DISABLED_PROJECT_INBOX,
|
||||||
|
title: "Inbox is not enabled for the project.",
|
||||||
|
description:
|
||||||
|
"Inbox helps you manage incoming requests to your project and add them as issues in your workflow. Enable inbox \n from project settings to manage requests.",
|
||||||
|
accessType: "project",
|
||||||
|
access: EUserProjectRoles.ADMIN,
|
||||||
|
path: "/empty-state/disabled-feature/inbox",
|
||||||
|
primaryButton: {
|
||||||
|
text: "Manage features",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[EmptyStateType.INBOX_SIDEBAR_OPEN_TAB]: {
|
||||||
|
key: EmptyStateType.INBOX_SIDEBAR_OPEN_TAB,
|
||||||
|
title: "No open issues",
|
||||||
|
description: "Find open issues here. Create new issue.",
|
||||||
|
path: "/empty-state/inbox/inbox-issue",
|
||||||
|
},
|
||||||
|
[EmptyStateType.INBOX_SIDEBAR_CLOSED_TAB]: {
|
||||||
|
key: EmptyStateType.INBOX_SIDEBAR_CLOSED_TAB,
|
||||||
|
title: "No closed issues",
|
||||||
|
description: "All the issues whether accepted or \n declined can be found here.",
|
||||||
|
path: "/empty-state/inbox/inbox-issue",
|
||||||
|
},
|
||||||
|
[EmptyStateType.INBOX_SIDEBAR_FILTER_EMPTY_STATE]: {
|
||||||
|
key: EmptyStateType.INBOX_SIDEBAR_FILTER_EMPTY_STATE,
|
||||||
|
title: "No matching issues",
|
||||||
|
description: "No issue matches filter applied in inbox. \n Create a new issue.",
|
||||||
|
path: "/empty-state/inbox/filter-issue",
|
||||||
|
},
|
||||||
|
[EmptyStateType.INBOX_DETAIL_EMPTY_STATE]: {
|
||||||
|
key: EmptyStateType.INBOX_DETAIL_EMPTY_STATE,
|
||||||
|
title: "Select an issue to view its details.",
|
||||||
|
path: "/empty-state/inbox/issue-detail",
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const EMPTY_STATE_DETAILS: Record<EmptyStateType, EmptyStateDetails> = emptyStateDetails;
|
export const EMPTY_STATE_DETAILS: Record<EmptyStateType, EmptyStateDetails> = emptyStateDetails;
|
||||||
|
@ -1,91 +1,90 @@
|
|||||||
// icons
|
// icons
|
||||||
import { AlertTriangle, CheckCircle2, Clock, Copy, ExternalLink, LucideIcon, XCircle } from "lucide-react";
|
import { AlertTriangle, CheckCircle2, Clock, Copy, LucideIcon, XCircle } from "lucide-react";
|
||||||
|
// types
|
||||||
|
import { TInboxIssueSortingOrderByKeys, TInboxIssueSortingSortByKeys, TInboxIssueStatus } from "@plane/types";
|
||||||
// helpers
|
// helpers
|
||||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
import { findHowManyDaysLeft } from "@/helpers/date-time.helper";
|
||||||
|
|
||||||
export const INBOX_STATUS: {
|
export const INBOX_STATUS: {
|
||||||
key: string;
|
key: string;
|
||||||
status: number;
|
status: TInboxIssueStatus;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
title: string;
|
title: string;
|
||||||
description: (
|
description: (snoozedTillDate: Date) => string;
|
||||||
workspaceSlug: string,
|
|
||||||
projectId: string,
|
|
||||||
issueId: string,
|
|
||||||
snoozedTillDate: Date | undefined
|
|
||||||
) => JSX.Element;
|
|
||||||
textColor: (snoozeDatePassed: boolean) => string;
|
textColor: (snoozeDatePassed: boolean) => string;
|
||||||
bgColor: (snoozeDatePassed: boolean) => string;
|
bgColor: (snoozeDatePassed: boolean) => string;
|
||||||
borderColor: (snoozeDatePassed: boolean) => string;
|
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
key: "pending",
|
key: "pending",
|
||||||
status: -2,
|
status: -2,
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
title: "Pending",
|
title: "Pending",
|
||||||
description: () => <p>This issue is still pending.</p>,
|
description: () => `Pending`,
|
||||||
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-yellow-500"),
|
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-[#AB6400]"),
|
||||||
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-yellow-500/10"),
|
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-[#FFF7C2]"),
|
||||||
borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "border-yellow-500"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "declined",
|
key: "declined",
|
||||||
status: -1,
|
status: -1,
|
||||||
icon: XCircle,
|
icon: XCircle,
|
||||||
title: "Declined",
|
title: "Declined",
|
||||||
description: () => <p>This issue has been declined.</p>,
|
description: () => `Declined`,
|
||||||
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-red-500"),
|
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-[#CE2C31]"),
|
||||||
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-red-500/10"),
|
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-[#FEEBEC]"),
|
||||||
borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "border-red-500"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "snoozed",
|
key: "snoozed",
|
||||||
status: 0,
|
status: 0,
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
title: "Snoozed",
|
title: "Snoozed",
|
||||||
description: (workspaceSlug: string, projectId: string, issueId: string, snoozedTillDate: Date = new Date()) =>
|
description: (snoozedTillDate: Date = new Date()) => `${findHowManyDaysLeft(snoozedTillDate)} days to go`,
|
||||||
snoozedTillDate < new Date() ? (
|
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "text-red-500" : "text-custom-text-400"),
|
||||||
<p>This issue was snoozed till {renderFormattedDate(snoozedTillDate)}.</p>
|
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "bg-red-500/10" : "bg-[#E0E1E6]"),
|
||||||
) : (
|
|
||||||
<p>This issue has been snoozed till {renderFormattedDate(snoozedTillDate)}.</p>
|
|
||||||
),
|
|
||||||
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "text-red-500" : "text-custom-text-200"),
|
|
||||||
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "bg-red-500/10" : "bg-gray-500/10"),
|
|
||||||
borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "border-red-500" : "border-gray-500"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "accepted",
|
key: "accepted",
|
||||||
status: 1,
|
status: 1,
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
title: "Accepted",
|
title: "Accepted",
|
||||||
description: () => <p>This issue has been accepted.</p>,
|
description: () => `Accepted`,
|
||||||
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-green-500"),
|
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-[#3E9B4F]"),
|
||||||
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-green-500/10"),
|
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-[#E9F6E9]"),
|
||||||
borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "border-green-500"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "duplicate",
|
key: "duplicate",
|
||||||
status: 2,
|
status: 2,
|
||||||
icon: Copy,
|
icon: Copy,
|
||||||
title: "Duplicate",
|
title: "Duplicate",
|
||||||
description: (workspaceSlug: string, projectId: string, issueId: string) => (
|
description: () => `Duplicate`,
|
||||||
<p className="flex items-center gap-1">
|
|
||||||
This issue has been marked as a duplicate of
|
|
||||||
<a
|
|
||||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="flex items-center gap-2 underline"
|
|
||||||
>
|
|
||||||
this issue <ExternalLink size={12} strokeWidth={2} />
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
),
|
|
||||||
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-custom-text-200"),
|
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-custom-text-200"),
|
||||||
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-gray-500/10"),
|
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-gray-500/10"),
|
||||||
borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "border-gray-500"),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const INBOX_ISSUE_SOURCE = "in-app";
|
export const INBOX_ISSUE_SOURCE = "in-app";
|
||||||
|
|
||||||
|
export const INBOX_ISSUE_ORDER_BY_OPTIONS: { key: TInboxIssueSortingOrderByKeys; label: string }[] = [
|
||||||
|
{
|
||||||
|
key: "issue__created_at",
|
||||||
|
label: "Date created",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "issue__updated_at",
|
||||||
|
label: "Date updated",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "issue__sequence_id",
|
||||||
|
label: "ID",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const INBOX_ISSUE_SORT_BY_OPTIONS: { key: TInboxIssueSortingSortByKeys; label: string }[] = [
|
||||||
|
{
|
||||||
|
key: "asc",
|
||||||
|
label: "Ascending",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "desc",
|
||||||
|
label: "Descending",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
@ -23,5 +23,6 @@ export * from "./use-workspace";
|
|||||||
export * from "./use-issues";
|
export * from "./use-issues";
|
||||||
export * from "./use-kanban-view";
|
export * from "./use-kanban-view";
|
||||||
export * from "./use-issue-detail";
|
export * from "./use-issue-detail";
|
||||||
export * from "./use-inbox";
|
// project inbox
|
||||||
|
export * from "./use-project-inbox";
|
||||||
export * from "./use-inbox-issues";
|
export * from "./use-inbox-issues";
|
||||||
|
@ -1,15 +1,9 @@
|
|||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
// mobx store
|
// mobx store
|
||||||
import { StoreContext } from "@/contexts/store-context";
|
import { StoreContext } from "contexts/store-context";
|
||||||
// types
|
|
||||||
import { IInboxFilter } from "@/store/inbox/inbox_filter.store";
|
|
||||||
import { IInboxIssue } from "@/store/inbox/inbox_issue.store";
|
|
||||||
|
|
||||||
export const useInboxIssues = (): {
|
export const useInboxIssues = (inboxIssueId: string) => {
|
||||||
issues: IInboxIssue;
|
|
||||||
filters: IInboxFilter;
|
|
||||||
} => {
|
|
||||||
const context = useContext(StoreContext);
|
const context = useContext(StoreContext);
|
||||||
if (context === undefined) throw new Error("useInboxIssues must be used within StoreProvider");
|
if (context === undefined) throw new Error("useInboxIssues must be used within StoreProvider");
|
||||||
return { issues: context.inbox.inboxIssue, filters: context.inbox.inboxFilter };
|
return context.projectInbox.getIssueInboxByIssueId(inboxIssueId) || {};
|
||||||
};
|
};
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
import { useContext } from "react";
|
|
||||||
// mobx store
|
|
||||||
import { StoreContext } from "@/contexts/store-context";
|
|
||||||
// types
|
|
||||||
import { IInbox } from "@/store/inbox/inbox.store";
|
|
||||||
|
|
||||||
export const useInbox = (): IInbox => {
|
|
||||||
const context = useContext(StoreContext);
|
|
||||||
if (context === undefined) throw new Error("useInbox must be used within StoreProvider");
|
|
||||||
return context.inbox.inbox;
|
|
||||||
};
|
|
11
web/hooks/store/use-project-inbox.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
// mobx store
|
||||||
|
import { StoreContext } from "contexts/store-context";
|
||||||
|
// types
|
||||||
|
import { IProjectInboxStore } from "@/store/inbox/project-inbox.store";
|
||||||
|
|
||||||
|
export const useProjectInbox = (): IProjectInboxStore => {
|
||||||
|
const context = useContext(StoreContext);
|
||||||
|
if (context === undefined) throw new Error("useProjectInbox must be used within StoreProvider");
|
||||||
|
return context.projectInbox;
|
||||||
|
};
|
42
web/hooks/use-intersection-observer.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { RefObject, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export type UseIntersectionObserverProps = {
|
||||||
|
containerRef: RefObject<HTMLDivElement>;
|
||||||
|
elementRef: RefObject<HTMLDivElement>;
|
||||||
|
callback: () => void;
|
||||||
|
rootMargin?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useIntersectionObserver = (props: UseIntersectionObserverProps) => {
|
||||||
|
const { containerRef, elementRef, callback, rootMargin = "0px" } = props;
|
||||||
|
const [isVisible, setVisibility] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (elementRef.current) {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
setVisibility(entry.isIntersecting);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: containerRef.current,
|
||||||
|
rootMargin,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
observer.observe(elementRef.current);
|
||||||
|
return () => {
|
||||||
|
if (elementRef.current) {
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
observer.unobserve(elementRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// while removing the current from the refs, the observer is not not working as expected
|
||||||
|
// fix this eslint warning with caution
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rootMargin, callback, elementRef.current, containerRef.current]);
|
||||||
|
|
||||||
|
return isVisible;
|
||||||
|
};
|
@ -19,7 +19,7 @@ import {
|
|||||||
useProjectState,
|
useProjectState,
|
||||||
useProjectView,
|
useProjectView,
|
||||||
useUser,
|
useUser,
|
||||||
useInbox,
|
// useInbox,
|
||||||
} from "@/hooks/store";
|
} from "@/hooks/store";
|
||||||
// images
|
// images
|
||||||
import emptyProject from "public/empty-state/project.svg";
|
import emptyProject from "public/empty-state/project.svg";
|
||||||
@ -31,7 +31,7 @@ interface IProjectAuthWrapper {
|
|||||||
export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
|
export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
// store
|
// store
|
||||||
const { fetchInboxes } = useInbox();
|
// const { fetchInboxes } = useInbox();
|
||||||
const {
|
const {
|
||||||
commandPalette: { toggleCreateProjectModal },
|
commandPalette: { toggleCreateProjectModal },
|
||||||
} = useApplication();
|
} = useApplication();
|
||||||
@ -39,7 +39,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
|
|||||||
const {
|
const {
|
||||||
membership: { fetchUserProjectInfo, projectMemberInfo, hasPermissionToProject },
|
membership: { fetchUserProjectInfo, projectMemberInfo, hasPermissionToProject },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
const { getProjectById, fetchProjectDetails, currentProjectDetails } = useProject();
|
const { getProjectById, fetchProjectDetails } = useProject();
|
||||||
const { fetchAllCycles } = useCycle();
|
const { fetchAllCycles } = useCycle();
|
||||||
const { fetchModules } = useModule();
|
const { fetchModules } = useModule();
|
||||||
const { fetchViews } = useProjectView();
|
const { fetchViews } = useProjectView();
|
||||||
@ -105,20 +105,6 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
|
|||||||
workspaceSlug && projectId ? () => fetchViews(workspaceSlug.toString(), projectId.toString()) : null,
|
workspaceSlug && projectId ? () => fetchViews(workspaceSlug.toString(), projectId.toString()) : null,
|
||||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||||
);
|
);
|
||||||
// fetching project inboxes if inbox is enabled in project settings
|
|
||||||
useSWR(
|
|
||||||
workspaceSlug && projectId && currentProjectDetails && currentProjectDetails.inbox_view
|
|
||||||
? `PROJECT_INBOXES_${workspaceSlug}_${projectId}`
|
|
||||||
: null,
|
|
||||||
workspaceSlug && projectId && currentProjectDetails && currentProjectDetails.inbox_view
|
|
||||||
? () => fetchInboxes(workspaceSlug.toString(), projectId.toString())
|
|
||||||
: null,
|
|
||||||
{
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
revalidateOnReconnect: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectExists = projectId ? getProjectById(projectId.toString()) : null;
|
const projectExists = projectId ? getProjectById(projectId.toString()) : null;
|
||||||
|
|
||||||
// check if the project member apis is loading
|
// check if the project member apis is loading
|
||||||
|
1
web/next-env.d.ts
vendored
@ -1,6 +1,5 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
/// <reference types="next/navigation-types/compat/navigation" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
|
@ -1,80 +0,0 @@
|
|||||||
import { ReactElement } from "react";
|
|
||||||
import { observer } from "mobx-react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import useSWR from "swr";
|
|
||||||
// hooks
|
|
||||||
import { PageHead } from "@/components/core";
|
|
||||||
import { ProjectInboxHeader } from "@/components/headers";
|
|
||||||
import { InboxSidebarRoot, InboxContentRoot } from "@/components/inbox";
|
|
||||||
import { InboxLayoutLoader } from "@/components/ui";
|
|
||||||
import { useProject, useInboxIssues } from "@/hooks/store";
|
|
||||||
// layouts
|
|
||||||
import { AppLayout } from "@/layouts/app-layout";
|
|
||||||
// components
|
|
||||||
// types
|
|
||||||
import { NextPageWithLayout } from "@/lib/types";
|
|
||||||
|
|
||||||
const ProjectInboxPage: NextPageWithLayout = observer(() => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query;
|
|
||||||
// store hooks
|
|
||||||
const { currentProjectDetails } = useProject();
|
|
||||||
const {
|
|
||||||
filters: { fetchInboxFilters },
|
|
||||||
issues: { fetchInboxIssues },
|
|
||||||
} = useInboxIssues();
|
|
||||||
// fetching the Inbox filters and issues
|
|
||||||
const { isLoading } = useSWR(
|
|
||||||
workspaceSlug && projectId && currentProjectDetails && currentProjectDetails?.inbox_view
|
|
||||||
? `INBOX_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}`
|
|
||||||
: null,
|
|
||||||
async () => {
|
|
||||||
if (workspaceSlug && projectId && inboxId && currentProjectDetails && currentProjectDetails?.inbox_view) {
|
|
||||||
await fetchInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString());
|
|
||||||
await fetchInboxIssues(workspaceSlug.toString(), projectId.toString(), inboxId.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// derived values
|
|
||||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Inbox` : undefined;
|
|
||||||
|
|
||||||
if (!workspaceSlug || !projectId || !inboxId || !currentProjectDetails?.inbox_view || isLoading)
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<InboxLayoutLoader />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHead title={pageTitle} />
|
|
||||||
<div className="relative flex h-full overflow-hidden">
|
|
||||||
<div className="flex-shrink-0 w-[340px] h-full border-r border-custom-border-300">
|
|
||||||
<InboxSidebarRoot
|
|
||||||
workspaceSlug={workspaceSlug.toString()}
|
|
||||||
projectId={projectId.toString()}
|
|
||||||
inboxId={inboxId.toString()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
|
||||||
<InboxContentRoot
|
|
||||||
workspaceSlug={workspaceSlug.toString()}
|
|
||||||
projectId={projectId.toString()}
|
|
||||||
inboxId={inboxId.toString()}
|
|
||||||
inboxIssueId={inboxIssueId?.toString() || undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ProjectInboxPage.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return (
|
|
||||||
<AppLayout header={<ProjectInboxHeader />} withProjectWrapper>
|
|
||||||
{page}
|
|
||||||
</AppLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProjectInboxPage;
|
|
@ -1,41 +1,54 @@
|
|||||||
import { ReactElement } from "react";
|
import { ReactElement } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import useSWR from "swr";
|
// components
|
||||||
// hooks
|
import { PageHead } from "@/components/core";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { ProjectInboxHeader } from "@/components/headers";
|
import { ProjectInboxHeader } from "@/components/headers";
|
||||||
import { InboxLayoutLoader } from "@/components/ui";
|
import { InboxIssueRoot } from "@/components/inbox";
|
||||||
import { useInbox, useProject } from "@/hooks/store";
|
// constants
|
||||||
|
import { EmptyStateType } from "@/constants/empty-state";
|
||||||
|
// hooks
|
||||||
|
import { useProject } from "@/hooks/store";
|
||||||
// layouts
|
// layouts
|
||||||
import { AppLayout } from "@/layouts/app-layout";
|
import { AppLayout } from "@/layouts/app-layout";
|
||||||
// ui
|
|
||||||
// components
|
|
||||||
// types
|
// types
|
||||||
import { NextPageWithLayout } from "@/lib/types";
|
import { NextPageWithLayout } from "@/lib/types";
|
||||||
|
|
||||||
const ProjectInboxPage: NextPageWithLayout = observer(() => {
|
const ProjectInboxPage: NextPageWithLayout = observer(() => {
|
||||||
|
/// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId, inboxIssueId } = router.query;
|
||||||
|
// hooks
|
||||||
const { currentProjectDetails } = useProject();
|
const { currentProjectDetails } = useProject();
|
||||||
const { fetchInboxes } = useInbox();
|
|
||||||
|
|
||||||
useSWR(
|
if (!workspaceSlug || !projectId) return <></>;
|
||||||
workspaceSlug && projectId && currentProjectDetails && currentProjectDetails?.inbox_view
|
|
||||||
? `INBOX_${workspaceSlug.toString()}_${projectId.toString()}`
|
// No access to inbox
|
||||||
: null,
|
if (currentProjectDetails?.inbox_view === false)
|
||||||
async () => {
|
return (
|
||||||
if (workspaceSlug && projectId && currentProjectDetails && currentProjectDetails?.inbox_view) {
|
<div className="flex items-center justify-center h-full w-full">
|
||||||
const inboxes = await fetchInboxes(workspaceSlug.toString(), projectId.toString());
|
<EmptyState
|
||||||
if (inboxes && inboxes.length > 0)
|
type={EmptyStateType.DISABLED_PROJECT_INBOX}
|
||||||
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/${inboxes[0].id}`);
|
primaryButtonLink={`/${workspaceSlug}/projects/${projectId}/settings/features`}
|
||||||
}
|
/>
|
||||||
}
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// derived values
|
||||||
|
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Inbox` : "Plane - Inbox";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{currentProjectDetails?.inbox_view ? <InboxLayoutLoader /> : <div>You don{"'"}t have access to inbox</div>}
|
<PageHead title={pageTitle} />
|
||||||
|
<div className="w-full h-full overflow-hidden">
|
||||||
|
<InboxIssueRoot
|
||||||
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
|
projectId={projectId.toString()}
|
||||||
|
inboxIssueId={inboxIssueId?.toString() || undefined}
|
||||||
|
inboxAccessible={currentProjectDetails?.inbox_view || false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
BIN
web/public/empty-state/disabled-feature/inbox-dark.webp
Normal file
After Width: | Height: | Size: 60 KiB |
BIN
web/public/empty-state/disabled-feature/inbox-light.webp
Normal file
After Width: | Height: | Size: 62 KiB |
BIN
web/public/empty-state/inbox/filter-issue-dark.webp
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
web/public/empty-state/inbox/filter-issue-light.webp
Normal file
After Width: | Height: | Size: 40 KiB |
BIN
web/public/empty-state/inbox/inbox-issue-dark.webp
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
web/public/empty-state/inbox/inbox-issue-light.webp
Normal file
After Width: | Height: | Size: 40 KiB |
BIN
web/public/empty-state/inbox/issue-detail-dark.webp
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
web/public/empty-state/inbox/issue-detail-light.webp
Normal file
After Width: | Height: | Size: 2.2 KiB |
@ -1,122 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
|
||||||
import { APIService } from "@/services/api.service";
|
|
||||||
// helpers
|
|
||||||
// types
|
|
||||||
import type { IInboxIssue, IInbox, TInboxStatus, IInboxQueryParams } from "@plane/types";
|
|
||||||
|
|
||||||
export class InboxService extends APIService {
|
|
||||||
constructor() {
|
|
||||||
super(API_BASE_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<IInboxIssue> {
|
|
||||||
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
|
|
||||||
): Promise<any> {
|
|
||||||
return this.delete(
|
|
||||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`
|
|
||||||
)
|
|
||||||
.then((response) => response?.data)
|
|
||||||
.catch((error) => {
|
|
||||||
throw error?.response?.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async markInboxStatus(
|
|
||||||
workspaceSlug: string,
|
|
||||||
projectId: string,
|
|
||||||
inboxId: string,
|
|
||||||
inboxIssueId: string,
|
|
||||||
data: TInboxStatus
|
|
||||||
): Promise<IInboxIssue> {
|
|
||||||
return this.patch(
|
|
||||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`,
|
|
||||||
data
|
|
||||||
)
|
|
||||||
.then((response) => response?.data)
|
|
||||||
.catch((error) => {
|
|
||||||
throw error?.response?.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async patchInboxIssue(
|
|
||||||
workspaceSlug: string,
|
|
||||||
projectId: string,
|
|
||||||
inboxId: string,
|
|
||||||
inboxIssueId: string,
|
|
||||||
data: { issue: Partial<IInboxIssue> }
|
|
||||||
): Promise<any> {
|
|
||||||
return this.patch(
|
|
||||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`,
|
|
||||||
data
|
|
||||||
)
|
|
||||||
.then((response) => response?.data)
|
|
||||||
.catch((error) => {
|
|
||||||
throw error?.response?.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createInboxIssue(workspaceSlug: string, projectId: string, inboxId: string, data: any): Promise<IInboxIssue> {
|
|
||||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/`, data)
|
|
||||||
.then((response) => response?.data)
|
|
||||||
.catch((error) => {
|
|
||||||
throw error?.response?.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,40 +1,27 @@
|
|||||||
|
// types
|
||||||
|
import type { TInboxIssue, TIssue, TInboxIssueWithPagination } from "@plane/types";
|
||||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||||
import { APIService } from "@/services/api.service";
|
import { APIService } from "@/services/api.service";
|
||||||
// helpers
|
// helpers
|
||||||
// types
|
|
||||||
import type { TInboxIssueFilterOptions, TInboxIssueExtendedDetail, TIssue, TInboxDetailedStatus } from "@plane/types";
|
|
||||||
|
|
||||||
export class InboxIssueService extends APIService {
|
export class InboxIssueService extends APIService {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(API_BASE_URL);
|
super(API_BASE_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchInboxIssues(
|
async list(workspaceSlug: string, projectId: string, params = {}): Promise<TInboxIssueWithPagination> {
|
||||||
workspaceSlug: string,
|
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/`, {
|
||||||
projectId: string,
|
|
||||||
inboxId: string,
|
|
||||||
params?: TInboxIssueFilterOptions | {}
|
|
||||||
): Promise<TInboxIssueExtendedDetail[]> {
|
|
||||||
return this.get(
|
|
||||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/?expand=issue_inbox`,
|
|
||||||
{
|
|
||||||
params,
|
params,
|
||||||
}
|
})
|
||||||
)
|
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchInboxIssueById(
|
async retrieve(workspaceSlug: string, projectId: string, inboxIssueId: string): Promise<TInboxIssue> {
|
||||||
workspaceSlug: string,
|
|
||||||
projectId: string,
|
|
||||||
inboxId: string,
|
|
||||||
inboxIssueId: string
|
|
||||||
): Promise<TInboxIssueExtendedDetail> {
|
|
||||||
return this.get(
|
return this.get(
|
||||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/?expand=issue_inbox`
|
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/?expand=issue_inbox`
|
||||||
)
|
)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@ -42,68 +29,47 @@ export class InboxIssueService extends APIService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createInboxIssue(
|
async create(workspaceSlug: string, projectId: string, data: Partial<TIssue>): Promise<TInboxIssue> {
|
||||||
workspaceSlug: string,
|
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/`, {
|
||||||
projectId: string,
|
source: "IN_APP",
|
||||||
inboxId: string,
|
issue: data,
|
||||||
data: {
|
})
|
||||||
source: string;
|
|
||||||
issue: Partial<TIssue>;
|
|
||||||
}
|
|
||||||
): Promise<TInboxIssueExtendedDetail> {
|
|
||||||
return this.post(
|
|
||||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/?expand=issue_inbox`,
|
|
||||||
data
|
|
||||||
)
|
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateInboxIssue(
|
async update(
|
||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
inboxId: string,
|
|
||||||
inboxIssueId: string,
|
inboxIssueId: string,
|
||||||
data: { issue: Partial<TIssue> }
|
data: Partial<TInboxIssue>
|
||||||
): Promise<TInboxIssueExtendedDetail> {
|
): Promise<TInboxIssue> {
|
||||||
return this.patch(
|
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/`, data)
|
||||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/?expand=issue_inbox`,
|
|
||||||
data
|
|
||||||
)
|
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeInboxIssue(
|
async updateIssue(
|
||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
inboxId: string,
|
|
||||||
inboxIssueId: string
|
|
||||||
): Promise<void> {
|
|
||||||
return this.delete(
|
|
||||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`
|
|
||||||
)
|
|
||||||
.then((response) => response?.data)
|
|
||||||
.catch((error) => {
|
|
||||||
throw error?.response?.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateInboxIssueStatus(
|
|
||||||
workspaceSlug: string,
|
|
||||||
projectId: string,
|
|
||||||
inboxId: string,
|
|
||||||
inboxIssueId: string,
|
inboxIssueId: string,
|
||||||
data: TInboxDetailedStatus
|
data: Partial<TIssue>
|
||||||
): Promise<TInboxIssueExtendedDetail> {
|
): Promise<TInboxIssue> {
|
||||||
return this.patch(
|
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/`, {
|
||||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/?expand=issue_inbox`,
|
issue: data,
|
||||||
data
|
})
|
||||||
)
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(workspaceSlug: string, projectId: string, inboxIssueId: string): Promise<void> {
|
||||||
|
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/`)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
|
||||||
import { APIService } from "@/services/api.service";
|
|
||||||
// helpers
|
|
||||||
// types
|
|
||||||
import type { TInbox } from "@plane/types";
|
|
||||||
|
|
||||||
export class InboxService extends APIService {
|
|
||||||
constructor() {
|
|
||||||
super(API_BASE_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchInboxes(workspaceSlug: string, projectId: string): Promise<TInbox[]> {
|
|
||||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/`)
|
|
||||||
.then((response) => response?.data)
|
|
||||||
.catch((error) => {
|
|
||||||
throw error?.response?.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchInboxById(workspaceSlug: string, projectId: string, inboxId: string): Promise<TInbox> {
|
|
||||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`)
|
|
||||||
.then((response) => response?.data)
|
|
||||||
.catch((error) => {
|
|
||||||
throw error?.response?.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateInbox(workspaceSlug: string, projectId: string, inboxId: string, data: Partial<TInbox>): Promise<TInbox> {
|
|
||||||
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`, data)
|
|
||||||
.then((response) => response?.data)
|
|
||||||
.catch((error) => {
|
|
||||||
throw error?.response?.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,2 +1 @@
|
|||||||
export * from "./inbox.service";
|
|
||||||
export * from "./inbox-issue.service";
|
export * from "./inbox-issue.service";
|
||||||
|
139
web/store/inbox/inbox-issue.store.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import set from "lodash/set";
|
||||||
|
import { makeObservable, observable, runInAction, action } from "mobx";
|
||||||
|
// services
|
||||||
|
// types
|
||||||
|
import { TIssue, TInboxIssue, TInboxIssueStatus } from "@plane/types";
|
||||||
|
import { InboxIssueService } from "@/services/inbox";
|
||||||
|
|
||||||
|
export interface IInboxIssueStore {
|
||||||
|
isLoading: boolean;
|
||||||
|
id: string;
|
||||||
|
status: TInboxIssueStatus;
|
||||||
|
issue: Partial<TIssue>;
|
||||||
|
snoozed_till: Date | undefined;
|
||||||
|
duplicate_to: string | undefined;
|
||||||
|
created_by: string | undefined;
|
||||||
|
// actions
|
||||||
|
updateInboxIssueStatus: (status: TInboxIssueStatus) => Promise<void>; // accept, decline
|
||||||
|
updateInboxIssueDuplicateTo: (issueId: string) => Promise<void>; // connecting the inbox issue to the project existing issue
|
||||||
|
updateInboxIssueSnoozeTill: (date: Date) => Promise<void>; // snooze the issue
|
||||||
|
updateIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InboxIssueStore implements IInboxIssueStore {
|
||||||
|
// observables
|
||||||
|
isLoading: boolean = false;
|
||||||
|
id: string;
|
||||||
|
status: TInboxIssueStatus = -2;
|
||||||
|
issue: Partial<TIssue> = {};
|
||||||
|
snoozed_till: Date | undefined;
|
||||||
|
duplicate_to: string | undefined;
|
||||||
|
created_by: string | undefined;
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
// services
|
||||||
|
inboxIssueService;
|
||||||
|
|
||||||
|
constructor(workspaceSlug: string, projectId: string, data: TInboxIssue) {
|
||||||
|
this.id = data.id;
|
||||||
|
this.status = data.status;
|
||||||
|
this.issue = data?.issue;
|
||||||
|
this.snoozed_till = data?.snoozed_till ? new Date(data.snoozed_till) : undefined;
|
||||||
|
this.duplicate_to = data?.duplicate_to || undefined;
|
||||||
|
this.created_by = data?.created_by || undefined;
|
||||||
|
this.workspaceSlug = workspaceSlug;
|
||||||
|
this.projectId = projectId;
|
||||||
|
// services
|
||||||
|
this.inboxIssueService = new InboxIssueService();
|
||||||
|
// observable variables should be defined after the initialization of the values
|
||||||
|
makeObservable(this, {
|
||||||
|
id: observable,
|
||||||
|
status: observable,
|
||||||
|
issue: observable,
|
||||||
|
snoozed_till: observable,
|
||||||
|
duplicate_to: observable,
|
||||||
|
created_by: observable,
|
||||||
|
// actions
|
||||||
|
updateInboxIssueStatus: action,
|
||||||
|
updateInboxIssueDuplicateTo: action,
|
||||||
|
updateInboxIssueSnoozeTill: action,
|
||||||
|
updateIssue: action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInboxIssueStatus = async (status: TInboxIssueStatus) => {
|
||||||
|
const previousData: Partial<TInboxIssue> = {
|
||||||
|
status: this.status,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (!this.issue.id) return;
|
||||||
|
set(this, "status", status);
|
||||||
|
await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
|
||||||
|
status: status,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
runInAction(() => set(this, "status", previousData.status));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateInboxIssueDuplicateTo = async (issueId: string) => {
|
||||||
|
const inboxStatus = 2;
|
||||||
|
const previousData: Partial<TInboxIssue> = {
|
||||||
|
status: this.status,
|
||||||
|
duplicate_to: this.duplicate_to,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (!this.issue.id) return;
|
||||||
|
set(this, "status", inboxStatus);
|
||||||
|
set(this, "duplicate_to", issueId);
|
||||||
|
await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
|
||||||
|
status: inboxStatus,
|
||||||
|
duplicate_to: issueId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
runInAction(() => {
|
||||||
|
set(this, "status", previousData.status);
|
||||||
|
set(this, "duplicate_to", previousData.duplicate_to);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateInboxIssueSnoozeTill = async (date: Date) => {
|
||||||
|
const inboxStatus = 0;
|
||||||
|
const previousData: Partial<TInboxIssue> = {
|
||||||
|
status: this.status,
|
||||||
|
snoozed_till: this.snoozed_till,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (!this.issue.id) return;
|
||||||
|
set(this, "status", inboxStatus);
|
||||||
|
set(this, "snoozed_till", date);
|
||||||
|
await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
|
||||||
|
status: inboxStatus,
|
||||||
|
snoozed_till: new Date(date),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
runInAction(() => {
|
||||||
|
set(this, "status", previousData.status);
|
||||||
|
set(this, "snoozed_till", previousData.snoozed_till);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateIssue = async (issue: Partial<TIssue>) => {
|
||||||
|
const inboxIssue = this.issue;
|
||||||
|
try {
|
||||||
|
if (!this.issue.id) return;
|
||||||
|
Object.keys(issue).forEach((key) => {
|
||||||
|
const issueKey = key as keyof TIssue;
|
||||||
|
set(inboxIssue, issueKey, issue[issueKey]);
|
||||||
|
});
|
||||||
|
await this.inboxIssueService.updateIssue(this.workspaceSlug, this.projectId, this.issue.id, issue);
|
||||||
|
} catch {
|
||||||
|
Object.keys(issue).forEach((key) => {
|
||||||
|
const issueKey = key as keyof TIssue;
|
||||||
|
set(inboxIssue, issueKey, inboxIssue[issueKey]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|