fix: merge conflicts resolved

This commit is contained in:
sriram veeraghanta
2024-04-08 20:08:10 +05:30
155 changed files with 3678 additions and 2893 deletions

View File

@@ -27,7 +27,7 @@ RUN yarn install
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build
@@ -89,21 +89,17 @@ RUN chmod -R 777 /code
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 plane
RUN adduser --system --uid 1001 captain
COPY --from=installer /app/apps/app/next.config.js .
COPY --from=installer /app/apps/app/package.json .
COPY --from=installer /app/apps/space/next.config.js .
COPY --from=installer /app/apps/space/package.json .
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
COPY --from=installer /app/apps/app/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
COPY --from=installer /app/apps/app/.next/static ./apps/app/.next/static
COPY --from=installer --chown=captain:plane /app/apps/space/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/space/.next ./apps/space/.next
COPY --from=installer /app/apps/space/.next/standalone ./
COPY --from=installer /app/apps/space/.next ./apps/space/.next
ENV NEXT_TELEMETRY_DISABLED 1
@@ -118,7 +114,6 @@ ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
USER root
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh

View File

@@ -32,28 +32,18 @@ RUN apk add --no-cache --virtual .build-deps \
apk del .build-deps
RUN addgroup -S plane && \
adduser -S captain -G plane
RUN chown captain.plane /code
USER captain
# Add in Django deps and generate Django's static files
COPY manage.py manage.py
COPY plane plane/
COPY templates templates/
COPY package.json package.json
USER root
RUN apk --no-cache add "bash~=5.2"
COPY ./bin ./bin/
RUN mkdir -p /code/plane/logs
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
RUN chmod -R 777 /code
RUN chown -R captain:plane /code
USER captain
# Expose container port and run entry point script
EXPOSE 8000

View File

@@ -30,17 +30,13 @@ ADD requirements ./requirements
# Install the local development settings
RUN pip install -r requirements/local.txt --compile --no-cache-dir
RUN addgroup -S plane && \
adduser -S captain -G plane
COPY . .
RUN mkdir -p /code/plane/logs
RUN chown -R captain.plane /code
RUN chmod -R +x /code/bin
RUN chmod -R 777 /code
USER captain
# Expose container port and run entry point script
EXPOSE 8000

View File

@@ -66,11 +66,11 @@ class BaseSerializer(serializers.ModelSerializer):
if expand in self.fields:
# Import all the expandable serializers
from . import (
WorkspaceLiteSerializer,
ProjectLiteSerializer,
UserLiteSerializer,
StateLiteSerializer,
IssueSerializer,
ProjectLiteSerializer,
StateLiteSerializer,
UserLiteSerializer,
WorkspaceLiteSerializer,
)
# Expansion mapper

View File

@@ -79,7 +79,7 @@ class IssueSerializer(BaseSerializer):
parsed_str = html.tostring(parsed, encoding="unicode")
data["description_html"] = parsed_str
except Exception as e:
except Exception:
raise serializers.ValidationError("Invalid HTML passed")
# Validate assignees are from project
@@ -366,7 +366,7 @@ class IssueCommentSerializer(BaseSerializer):
parsed_str = html.tostring(parsed, encoding="unicode")
data["comment_html"] = parsed_str
except Exception as e:
except Exception:
raise serializers.ValidationError("Invalid HTML passed")
return data

View File

@@ -7,6 +7,7 @@ from plane.db.models import (
ProjectIdentifier,
WorkspaceMember,
)
from .base import BaseSerializer

View File

@@ -1,5 +1,6 @@
# Module imports
from plane.db.models import User
from .base import BaseSerializer
@@ -10,7 +11,9 @@ class UserLiteSerializer(BaseSerializer):
"id",
"first_name",
"last_name",
"email",
"avatar",
"display_name",
"email",
]
read_only_fields = fields

View File

@@ -12,7 +12,7 @@ urlpatterns = [
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/",
"workspaces/<str:slug>/projects/<uuid:pk>/",
ProjectAPIEndpoint.as_view(),
name="project",
),

View File

@@ -7,6 +7,7 @@ import zoneinfo
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
from django.urls import resolve
from django.utils import timezone
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
@@ -165,7 +166,12 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
@property
def project_id(self):
return self.kwargs.get("project_id", None)
project_id = self.kwargs.get("project_id", None)
if project_id:
return project_id
if resolve(self.request.path_info).url_name == "project":
return self.kwargs.get("pk", None)
@property
def fields(self):

View File

@@ -2,29 +2,31 @@
import json
# Django imports
from django.db.models import Q, Count, Sum, F, OuterRef, Func
from django.utils import timezone
from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Q, Sum
from django.utils import timezone
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView, WebhookMixin
from plane.db.models import (
Cycle,
Issue,
CycleIssue,
IssueLink,
IssueAttachment,
from plane.api.serializers import (
CycleIssueSerializer,
CycleSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.api.serializers import (
CycleSerializer,
CycleIssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Cycle,
CycleIssue,
Issue,
IssueAttachment,
IssueLink,
)
from plane.utils.analytics_plot import burndown_plot
from .base import BaseAPIView, WebhookMixin
class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
@@ -551,7 +553,21 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.distinct()
)
def get(self, request, slug, project_id, cycle_id):
def get(self, request, slug, project_id, cycle_id, issue_id=None):
# Get
if issue_id:
cycle_issue = CycleIssue.objects.get(
workspace__slug=slug,
project_id=project_id,
cycle_id=cycle_id,
issue_id=issue_id,
)
serializer = CycleIssueSerializer(
cycle_issue, fields=self.fields, expand=self.expand
)
return Response(serializer.data, status=status.HTTP_200_OK)
# List
order_by = request.GET.get("order_by", "created_at")
issues = (
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
@@ -748,6 +764,209 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
)
old_cycle = (
Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
)
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle.first(),
slug=slug,
project_id=project_id,
cycle_id=cycle_id,
)
# Get the assignee distribution
assignee_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("display_name", "assignee_id", "avatar")
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"avatar": item["avatar"],
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.objects.filter(
issue_cycle__cycle_id=cycle_id,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Label distribution serilization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (
str(item["label_id"]) if item["label_id"] else None
),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
current_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
).first()
if current_cycle:
current_cycle.progress_snapshot = {
"total_issues": old_cycle.first().total_issues,
"completed_issues": old_cycle.first().completed_issues,
"cancelled_issues": old_cycle.first().cancelled_issues,
"started_issues": old_cycle.first().started_issues,
"unstarted_issues": old_cycle.first().unstarted_issues,
"backlog_issues": old_cycle.first().backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
}
# Save the snapshot of the current cycle
current_cycle.save(update_fields=["progress_snapshot"])
if (
new_cycle.end_date is not None
and new_cycle.end_date < timezone.now().date()

View File

@@ -2,27 +2,28 @@
import json
# Django improts
from django.utils import timezone
from django.db.models import Q
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Q
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.app.permissions import ProjectLitePermission
from plane.api.serializers import InboxIssueSerializer, IssueSerializer
from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Inbox,
InboxIssue,
Issue,
State,
ProjectMember,
Project,
Inbox,
ProjectMember,
State,
)
from plane.bgtasks.issue_activites_task import issue_activity
from .base import BaseAPIView
class InboxIssueAPIEndpoint(BaseAPIView):
@@ -134,10 +135,11 @@ class InboxIssueAPIEndpoint(BaseAPIView):
# Create or get state
state, _ = State.objects.get_or_create(
name="Triage",
group="backlog",
group="triage",
description="Default state for managing all Inbox Issues",
project_id=project_id,
color="#ff7700",
is_triage=True,
)
# create an issue
@@ -298,7 +300,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
)
# 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
state = State.objects.filter(
workspace__slug=slug,

View File

@@ -308,8 +308,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
external_id__isnull=False,
external_source__isnull=False,
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)

View File

@@ -2,32 +2,33 @@
import json
# Django imports
from django.db.models import Count, Prefetch, Q, F, Func, OuterRef
from django.utils import timezone
from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView, WebhookMixin
from plane.api.serializers import (
IssueSerializer,
ModuleIssueSerializer,
ModuleSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Project,
Module,
ModuleLink,
Issue,
ModuleIssue,
IssueAttachment,
IssueLink,
Module,
ModuleIssue,
ModuleLink,
Project,
)
from plane.api.serializers import (
ModuleSerializer,
ModuleIssueSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from .base import BaseAPIView, WebhookMixin
class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):

View File

@@ -1,27 +1,29 @@
# Django imports
from django.utils import timezone
from django.db import IntegrityError
from django.db.models import Exists, OuterRef, Q, F, Func, Subquery, Prefetch
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from plane.api.serializers import ProjectSerializer
from plane.app.permissions import ProjectBasePermission
# Module imports
from plane.db.models import (
Workspace,
Project,
ProjectMember,
ProjectDeployBoard,
State,
Cycle,
Module,
IssueProperty,
Inbox,
IssueProperty,
Module,
Project,
ProjectDeployBoard,
ProjectMember,
State,
Workspace,
)
from plane.app.permissions import ProjectBasePermission
from plane.api.serializers import ProjectSerializer
from .base import BaseAPIView, WebhookMixin
@@ -103,8 +105,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.distinct()
)
def get(self, request, slug, project_id=None):
if project_id is None:
def get(self, request, slug, pk=None):
if pk is None:
sort_order_query = ProjectMember.objects.filter(
member=request.user,
project_id=OuterRef("pk"),
@@ -135,7 +137,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
expand=self.expand,
).data,
)
project = self.get_queryset().get(workspace__slug=slug, pk=project_id)
project = self.get_queryset().get(workspace__slug=slug, pk=pk)
serializer = ProjectSerializer(
project,
fields=self.fields,
@@ -259,10 +261,10 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_410_GONE,
)
def patch(self, request, slug, project_id=None):
def patch(self, request, slug, pk):
try:
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=project_id)
project = Project.objects.get(pk=pk)
if project.archived_at:
return Response(
@@ -289,10 +291,11 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
# Create the triage state in Backlog group
State.objects.get_or_create(
name="Triage",
group="backlog",
group="triage",
description="Default state for managing all Inbox Issues",
project_id=project_id,
project_id=pk,
color="#ff7700",
is_triage=True,
)
project = (
@@ -322,8 +325,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_410_GONE,
)
def delete(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
def delete(self, request, slug, pk):
project = Project.objects.get(pk=pk, workspace__slug=slug)
project.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -1,16 +1,17 @@
# Django imports
from django.db import IntegrityError
from django.db.models import Q
from rest_framework import status
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from plane.api.serializers import StateSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Issue, State
# Module imports
from .base import BaseAPIView
from plane.api.serializers import StateSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import State, Issue
class StateAPIEndpoint(BaseAPIView):
@@ -28,8 +29,8 @@ class StateAPIEndpoint(BaseAPIView):
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(is_triage=False)
.filter(project__archived_at__isnull=True)
.filter(~Q(name="Triage"))
.select_related("project")
.select_related("workspace")
.distinct()
@@ -86,7 +87,11 @@ class StateAPIEndpoint(BaseAPIView):
def get(self, request, slug, project_id, state_id=None):
if state_id:
serializer = StateSerializer(self.get_queryset().get(pk=state_id))
serializer = StateSerializer(
self.get_queryset().get(pk=state_id),
fields=self.fields,
expand=self.expand,
)
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
@@ -101,7 +106,7 @@ class StateAPIEndpoint(BaseAPIView):
def delete(self, request, slug, project_id, state_id):
state = State.objects.get(
~Q(name="Triage"),
is_triage=False,
pk=state_id,
project_id=project_id,
workspace__slug=slug,

View File

@@ -1,8 +1,8 @@
# Third Party imports
from rest_framework.permissions import BasePermission, SAFE_METHODS
from rest_framework.permissions import SAFE_METHODS, BasePermission
# Module import
from plane.db.models import WorkspaceMember, ProjectMember
from plane.db.models import ProjectMember, WorkspaceMember
# Permission Mappings
Admin = 20

View File

@@ -62,6 +62,7 @@ from .issue import (
IssueFlatSerializer,
IssueStateSerializer,
IssueLinkSerializer,
IssueInboxSerializer,
IssueLiteSerializer,
IssueAttachmentSerializer,
IssueSubscriberSerializer,
@@ -110,6 +111,7 @@ from .inbox import (
InboxIssueSerializer,
IssueStateInboxSerializer,
InboxIssueLiteSerializer,
InboxIssueDetailSerializer,
)
from .analytic import AnalyticViewSerializer

View File

@@ -3,7 +3,11 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .issue import IssueFlatSerializer, LabelLiteSerializer
from .issue import (
IssueInboxSerializer,
LabelLiteSerializer,
IssueDetailSerializer,
)
from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
@@ -24,17 +28,58 @@ class InboxSerializer(BaseSerializer):
class InboxIssueSerializer(BaseSerializer):
issue_detail = IssueFlatSerializer(source="issue", read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
issue = IssueInboxSerializer(read_only=True)
class Meta:
model = InboxIssue
fields = "__all__"
fields = [
"id",
"status",
"duplicate_to",
"snoozed_till",
"source",
"issue",
"created_by",
]
read_only_fields = [
"project",
"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 Meta:

View File

@@ -620,6 +620,26 @@ class IssueStateSerializer(DynamicBaseSerializer):
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):
# ids
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
@@ -688,7 +708,7 @@ class IssueLiteSerializer(DynamicBaseSerializer):
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
is_subscribed = serializers.BooleanField()
is_subscribed = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [

View File

@@ -30,7 +30,7 @@ urlpatterns = [
name="inbox",
),
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(
{
"get": "list",
@@ -40,7 +40,7 @@ urlpatterns = [
name="inbox-issue",
),
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(
{
"get": "retrieve",

View File

@@ -22,18 +22,17 @@ from plane.db.models import (
InboxIssue,
Issue,
State,
Workspace,
IssueLink,
IssueAttachment,
ProjectMember,
IssueReaction,
IssueSubscriber,
)
from plane.app.serializers import (
IssueCreateSerializer,
IssueSerializer,
InboxSerializer,
InboxIssueSerializer,
IssueDetailSerializer,
InboxIssueDetailSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity
@@ -64,13 +63,20 @@ class InboxViewSet(BaseViewSet):
.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):
serializer.save(project_id=self.kwargs.get("project_id"))
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
)
).first()
# Handle default inbox delete
if inbox.is_default:
return Response(
@@ -98,7 +104,6 @@ class InboxIssueViewSet(BaseViewSet):
Issue.objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_inbox__inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
@@ -162,51 +167,50 @@ class InboxIssueViewSet(BaseViewSet):
)
).distinct()
def list(self, request, slug, project_id, inbox_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = (
self.get_queryset()
.filter(**filters)
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
)
if self.expand:
issues = IssueSerializer(
issue_queryset, expand=self.expand, many=True
).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",
def list(self, request, slug, project_id):
workspace = Workspace.objects.filter(slug=slug).first()
inbox_id = Inbox.objects.filter(
workspace_id=workspace.id, project_id=project_id
).first()
filters = issue_filters(request.GET, "GET", "issue__")
inbox_issue = (
InboxIssue.objects.filter(
inbox_id=inbox_id.id, project_id=project_id, **filters
)
return Response(
issues,
status=status.HTTP_200_OK,
.select_related("issue")
.prefetch_related(
"issue__labels",
)
.annotate(
label_ids=Coalesce(
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):
return Response(
{"error": "Name is required"},
@@ -229,10 +233,11 @@ class InboxIssueViewSet(BaseViewSet):
# Create or get state
state, _ = State.objects.get_or_create(
name="Triage",
group="backlog",
group="triage",
description="Default state for managing all Inbox Issues",
project_id=project_id,
color="#ff7700",
is_triage=True,
)
# create an issue
@@ -259,19 +264,25 @@ class InboxIssueViewSet(BaseViewSet):
notification=True,
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
InboxIssue.objects.create(
inbox_id=inbox_id,
inbox_issue = InboxIssue.objects.create(
inbox_id=inbox_id.id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
)
issue = self.get_queryset().filter(pk=issue.id).first()
serializer = IssueSerializer(issue, expand=self.expand)
serializer = InboxIssueDetailSerializer(inbox_issue)
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(
issue_id=issue_id,
workspace__slug=slug,
@@ -374,7 +385,7 @@ class InboxIssueViewSet(BaseViewSet):
)
# 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
state = State.objects.filter(
workspace__slug=slug,
@@ -384,60 +395,60 @@ class InboxIssueViewSet(BaseViewSet):
if state is not None:
issue.state = state
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(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
else:
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
serializer = InboxIssueDetailSerializer(inbox_issue).data
return Response(serializer, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
issue = (
self.get_queryset()
.filter(pk=issue_id)
def retrieve(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.select_related("issue")
.prefetch_related(
Prefetch(
"issue_reactions",
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"),
)
"issue__labels",
"issue__assignees",
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=OuterRef("pk"),
subscriber=request.user,
)
)
label_ids=Coalesce(
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=~Q(issue__labels__id__isnull=True),
),
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
return Response(
issue,
status=status.HTTP_200_OK,
)
def destroy(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()
if issue is None:
return Response(
{"error": "Requested object was not found"},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueDetailSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, issue_id):
inbox_issue = InboxIssue.objects.get(
issue_id=issue_id,
workspace__slug=slug,

View File

@@ -1,57 +1,59 @@
# Python imports
import json
# Django imports
from django.utils import timezone
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Q,
Case,
Value,
CharField,
When,
Exists,
Max,
)
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import UUIDField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
Case,
CharField,
Exists,
F,
Func,
Max,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
# Django imports
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet, BaseAPIView, WebhookMixin
from plane.app.serializers import (
IssuePropertySerializer,
IssueSerializer,
IssueCreateSerializer,
IssueDetailSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
ProjectLitePermission,
)
from plane.db.models import (
Project,
Issue,
IssueProperty,
IssueLink,
IssueAttachment,
IssueSubscriber,
IssueReaction,
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssuePropertySerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
IssueProperty,
IssueReaction,
IssueSubscriber,
Project,
)
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin
class IssueListEndpoint(BaseAPIView):

View File

@@ -120,7 +120,6 @@ class IssueDraftViewSet(BaseViewSet):
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = [

View File

@@ -393,10 +393,11 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
# Create the triage state in Backlog group
State.objects.get_or_create(
name="Triage",
group="backlog",
group="triage",
description="Default state for managing all Inbox Issues",
project_id=pk,
color="#ff7700",
is_triage=True,
)
project = (

View File

@@ -35,7 +35,7 @@ class StateViewSet(BaseViewSet):
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.filter(~Q(name="Triage"))
.filter(is_triage=False)
.select_related("project")
.select_related("workspace")
.distinct()
@@ -76,7 +76,7 @@ class StateViewSet(BaseViewSet):
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
def destroy(self, request, slug, project_id, pk):
state = State.objects.get(
~Q(name="Triage"),
is_triage=False,
pk=pk,
project_id=project_id,
workspace__slug=slug,

View File

@@ -202,16 +202,7 @@ def send_webhook(event, payload, kw, action, slug, bulk, current_site):
if webhooks:
if action in ["POST", "PATCH"]:
if bulk and event in ["cycle_issue", "module_issue"]:
event_data = IssueExpandSerializer(
Issue.objects.filter(
pk__in=[
str(event.get("issue")) for event in payload
]
).prefetch_related("issue_cycle", "issue_module"),
many=True,
).data
event = "issue"
action = "PATCH"
return
else:
event_data = [
get_model_data(
@@ -219,7 +210,7 @@ def send_webhook(event, payload, kw, action, slug, bulk, current_site):
event_id=(
payload.get("id")
if isinstance(payload, dict)
else None
else kw.get("pk")
),
many=False,
)

View File

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

View File

@@ -171,14 +171,14 @@ class Issue(ProjectBaseModel):
from plane.db.models import State
default_state = State.objects.filter(
~models.Q(name="Triage"),
~models.Q(is_triage=True),
project=self.project,
default=True,
).first()
# if there is no default state assign any random state
if default_state is None:
random_state = State.objects.filter(
~models.Q(name="Triage"), project=self.project
~models.Q(is_triage=True), project=self.project
).first()
self.state = random_state
else:

View File

@@ -21,10 +21,12 @@ class State(ProjectBaseModel):
("started", "Started"),
("completed", "Completed"),
("cancelled", "Cancelled"),
("triage", "Triage")
),
default="backlog",
max_length=20,
)
is_triage = models.BooleanField(default=False)
default = models.BooleanField(default=False)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)

View File

@@ -25,7 +25,7 @@ LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
# Logging configuration
LOGGING = {
"version": 1,
"disable_existing_loggers": False,

View File

@@ -1,17 +1,16 @@
# Python imports
from itertools import groupby
from datetime import timedelta
from itertools import groupby
# Django import
from django.db import models
from django.utils import timezone
from django.db.models.functions import TruncDate
from django.db.models import Count, F, Sum, Value, Case, When, CharField
from django.db.models import Case, CharField, Count, F, Sum, Value, When
from django.db.models.functions import (
Coalesce,
Concat,
ExtractMonth,
ExtractYear,
Concat,
TruncDate,
)
from django.utils import timezone
@@ -116,11 +115,16 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
total_issues = queryset.total_issues
if cycle_id:
# Get all dates between the two dates
date_range = [
queryset.start_date + timedelta(days=x)
for x in range((queryset.end_date - queryset.start_date).days + 1)
]
if queryset.end_date and queryset.start_date:
# Get all dates between the two dates
date_range = [
queryset.start_date + timedelta(days=x)
for x in range(
(queryset.end_date - queryset.start_date).days + 1
)
]
else:
date_range = []
chart_data = {str(date): 0 for date in date_range}

View File

@@ -83,25 +83,25 @@ def date_filter(filter, date_term, queries):
filter[f"{date_term}__lte"] = date_query[0]
def filter_state(params, filter, method):
def filter_state(params, filter, method, prefix=""):
if method == "GET":
states = [
item for item in params.get("state").split(",") if item != "null"
]
states = filter_valid_uuids(states)
if len(states) and "" not in states:
filter["state__in"] = states
filter[f"{prefix}state__in"] = states
else:
if (
params.get("state", None)
and len(params.get("state"))
and params.get("state") != "null"
):
filter["state__in"] = params.get("state")
filter[f"{prefix}state__in"] = params.get("state")
return filter
def filter_state_group(params, filter, method):
def filter_state_group(params, filter, method, prefix=""):
if method == "GET":
state_group = [
item
@@ -109,18 +109,18 @@ def filter_state_group(params, filter, method):
if item != "null"
]
if len(state_group) and "" not in state_group:
filter["state__group__in"] = state_group
filter[f"{prefix}state__group__in"] = state_group
else:
if (
params.get("state_group", None)
and len(params.get("state_group"))
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
def filter_estimate_point(params, filter, method):
def filter_estimate_point(params, filter, method, prefix=""):
if method == "GET":
estimate_points = [
item
@@ -128,18 +128,20 @@ def filter_estimate_point(params, filter, method):
if item != "null"
]
if len(estimate_points) and "" not in estimate_points:
filter["estimate_point__in"] = estimate_points
filter[f"{prefix}estimate_point__in"] = estimate_points
else:
if (
params.get("estimate_point", None)
and len(params.get("estimate_point"))
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
def filter_priority(params, filter, method):
def filter_priority(params, filter, method, prefix=""):
if method == "GET":
priorities = [
item
@@ -147,47 +149,47 @@ def filter_priority(params, filter, method):
if item != "null"
]
if len(priorities) and "" not in priorities:
filter["priority__in"] = priorities
filter[f"{prefix}priority__in"] = priorities
return filter
def filter_parent(params, filter, method):
def filter_parent(params, filter, method, prefix=""):
if method == "GET":
parents = [
item for item in params.get("parent").split(",") if item != "null"
]
parents = filter_valid_uuids(parents)
if len(parents) and "" not in parents:
filter["parent__in"] = parents
filter[f"{prefix}parent__in"] = parents
else:
if (
params.get("parent", None)
and len(params.get("parent"))
and params.get("parent") != "null"
):
filter["parent__in"] = params.get("parent")
filter[f"{prefix}parent__in"] = params.get("parent")
return filter
def filter_labels(params, filter, method):
def filter_labels(params, filter, method, prefix=""):
if method == "GET":
labels = [
item for item in params.get("labels").split(",") if item != "null"
]
labels = filter_valid_uuids(labels)
if len(labels) and "" not in labels:
filter["labels__in"] = labels
filter[f"{prefix}labels__in"] = labels
else:
if (
params.get("labels", None)
and len(params.get("labels"))
and params.get("labels") != "null"
):
filter["labels__in"] = params.get("labels")
filter[f"{prefix}labels__in"] = params.get("labels")
return filter
def filter_assignees(params, filter, method):
def filter_assignees(params, filter, method, prefix=""):
if method == "GET":
assignees = [
item
@@ -196,18 +198,18 @@ def filter_assignees(params, filter, method):
]
assignees = filter_valid_uuids(assignees)
if len(assignees) and "" not in assignees:
filter["assignees__in"] = assignees
filter[f"{prefix}assignees__in"] = assignees
else:
if (
params.get("assignees", None)
and len(params.get("assignees"))
and params.get("assignees") != "null"
):
filter["assignees__in"] = params.get("assignees")
filter[f"{prefix}assignees__in"] = params.get("assignees")
return filter
def filter_mentions(params, filter, method):
def filter_mentions(params, filter, method, prefix=""):
if method == "GET":
mentions = [
item
@@ -216,18 +218,20 @@ def filter_mentions(params, filter, method):
]
mentions = filter_valid_uuids(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:
if (
params.get("mentions", None)
and len(params.get("mentions"))
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
def filter_created_by(params, filter, method):
def filter_created_by(params, filter, method, prefix=""):
if method == "GET":
created_bys = [
item
@@ -236,94 +240,98 @@ def filter_created_by(params, filter, method):
]
created_bys = filter_valid_uuids(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:
if (
params.get("created_by", None)
and len(params.get("created_by"))
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
def filter_name(params, filter, method):
def filter_name(params, filter, method, prefix=""):
if params.get("name", "") != "":
filter["name__icontains"] = params.get("name")
filter[f"{prefix}name__icontains"] = params.get("name")
return filter
def filter_created_at(params, filter, method):
def filter_created_at(params, filter, method, prefix=""):
if method == "GET":
created_ats = params.get("created_at").split(",")
if len(created_ats) and "" not in created_ats:
date_filter(
filter=filter,
date_term="created_at__date",
date_term=f"{prefix}created_at__date",
queries=created_ats,
)
else:
if params.get("created_at", None) and len(params.get("created_at")):
date_filter(
filter=filter,
date_term="created_at__date",
date_term=f"{prefix}created_at__date",
queries=params.get("created_at", []),
)
return filter
def filter_updated_at(params, filter, method):
def filter_updated_at(params, filter, method, prefix=""):
if method == "GET":
updated_ats = params.get("updated_at").split(",")
if len(updated_ats) and "" not in updated_ats:
date_filter(
filter=filter,
date_term="created_at__date",
date_term=f"{prefix}created_at__date",
queries=updated_ats,
)
else:
if params.get("updated_at", None) and len(params.get("updated_at")):
date_filter(
filter=filter,
date_term="created_at__date",
date_term=f"{prefix}created_at__date",
queries=params.get("updated_at", []),
)
return filter
def filter_start_date(params, filter, method):
def filter_start_date(params, filter, method, prefix=""):
if method == "GET":
start_dates = params.get("start_date").split(",")
if len(start_dates) and "" not in start_dates:
date_filter(
filter=filter, date_term="start_date", queries=start_dates
filter=filter,
date_term=f"{prefix}start_date",
queries=start_dates,
)
else:
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
def filter_target_date(params, filter, method):
def filter_target_date(params, filter, method, prefix=""):
if method == "GET":
target_dates = params.get("target_date").split(",")
if len(target_dates) and "" not in target_dates:
date_filter(
filter=filter, date_term="target_date", queries=target_dates
filter=filter,
date_term=f"{prefix}target_date",
queries=target_dates,
)
else:
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
def filter_completed_at(params, filter, method):
def filter_completed_at(params, filter, method, prefix=""):
if method == "GET":
completed_ats = params.get("completed_at").split(",")
if len(completed_ats) and "" not in completed_ats:
date_filter(
filter=filter,
date_term="completed_at__date",
date_term=f"{prefix}completed_at__date",
queries=completed_ats,
)
else:
@@ -332,13 +340,13 @@ def filter_completed_at(params, filter, method):
):
date_filter(
filter=filter,
date_term="completed_at__date",
date_term=f"{prefix}completed_at__date",
queries=params.get("completed_at", []),
)
return filter
def filter_issue_state_type(params, filter, method):
def filter_issue_state_type(params, filter, method, prefix=""):
type = params.get("type", "all")
group = ["backlog", "unstarted", "started", "completed", "cancelled"]
if type == "backlog":
@@ -346,65 +354,67 @@ def filter_issue_state_type(params, filter, method):
if type == "active":
group = ["unstarted", "started"]
filter["state__group__in"] = group
filter[f"{prefix}state__group__in"] = group
return filter
def filter_project(params, filter, method):
def filter_project(params, filter, method, prefix=""):
if method == "GET":
projects = [
item for item in params.get("project").split(",") if item != "null"
]
projects = filter_valid_uuids(projects)
if len(projects) and "" not in projects:
filter["project__in"] = projects
filter[f"{prefix}project__in"] = projects
else:
if (
params.get("project", None)
and len(params.get("project"))
and params.get("project") != "null"
):
filter["project__in"] = params.get("project")
filter[f"{prefix}project__in"] = params.get("project")
return filter
def filter_cycle(params, filter, method):
def filter_cycle(params, filter, method, prefix=""):
if method == "GET":
cycles = [
item for item in params.get("cycle").split(",") if item != "null"
]
cycles = filter_valid_uuids(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:
if (
params.get("cycle", None)
and len(params.get("cycle"))
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
def filter_module(params, filter, method):
def filter_module(params, filter, method, prefix=""):
if method == "GET":
modules = [
item for item in params.get("module").split(",") if item != "null"
]
modules = filter_valid_uuids(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:
if (
params.get("module", None)
and len(params.get("module"))
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
def filter_inbox_status(params, filter, method):
def filter_inbox_status(params, filter, method, prefix=""):
if method == "GET":
status = [
item
@@ -412,30 +422,32 @@ def filter_inbox_status(params, filter, method):
if item != "null"
]
if len(status) and "" not in status:
filter["issue_inbox__status__in"] = status
filter[f"{prefix}issue_inbox__status__in"] = status
else:
if (
params.get("inbox_status", None)
and len(params.get("inbox_status"))
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
def filter_sub_issue_toggle(params, filter, method):
def filter_sub_issue_toggle(params, filter, method, prefix=""):
if method == "GET":
sub_issue = params.get("sub_issue", "false")
if sub_issue == "false":
filter["parent__isnull"] = True
filter[f"{prefix}parent__isnull"] = True
else:
sub_issue = params.get("sub_issue", "false")
if sub_issue == "false":
filter["parent__isnull"] = True
filter[f"{prefix}parent__isnull"] = True
return filter
def filter_subscribed_issues(params, filter, method):
def filter_subscribed_issues(params, filter, method, prefix=""):
if method == "GET":
subscribers = [
item
@@ -444,28 +456,30 @@ def filter_subscribed_issues(params, filter, method):
]
subscribers = filter_valid_uuids(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:
if (
params.get("subscriber", None)
and len(params.get("subscriber"))
and params.get("subscriber") != "null"
):
filter["issue_subscribers__subscriber_id__in"] = params.get(
"subscriber"
filter[f"{prefix}issue_subscribers__subscriber_id__in"] = (
params.get("subscriber")
)
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")
if start_target_date == "true":
filter["target_date__isnull"] = False
filter["start_date__isnull"] = False
filter[f"{prefix}target_date__isnull"] = False
filter[f"{prefix}start_date__isnull"] = False
return filter
def issue_filters(query_params, method):
def issue_filters(query_params, method, prefix=""):
filter = {}
ISSUE_FILTER = {
@@ -497,6 +511,5 @@ def issue_filters(query_params, method):
for key, value in ISSUE_FILTER.items():
if key in query_params:
func = value
func(query_params, filter, method)
func(query_params, filter, method, prefix)
return filter

View File

@@ -134,7 +134,7 @@ class OffsetPaginator:
results=results,
next=next_cursor,
prev=prev_cursor,
hits=None,
hits=count,
max_hits=max_hits,
)
@@ -217,6 +217,7 @@ class BasePaginator:
"prev_page_results": cursor_result.prev.has_results,
"count": cursor_result.__len__(),
"total_pages": cursor_result.max_hits,
"total_results": cursor_result.hits,
"extra_stats": extra_stats,
"results": results,
}

11
packages/types/src/common.d.ts vendored Normal file
View 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
View 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[];
};

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
export * from "./inbox-issue";
export * from "./inbox-types";
export * from "./inbox";

View File

@@ -12,10 +12,7 @@ export * from "./pages";
export * from "./ai";
export * from "./estimate";
export * from "./importer";
// FIXME: Remove this after development and the refactor/mobx-store-issue branch is stable
export * from "./inbox/root";
export * from "./inbox";
export * from "./analytics";
export * from "./calendar";
export * from "./notifications";
@@ -29,14 +26,6 @@ export * from "./auth";
export * from "./api_token";
export * from "./instance";
export * from "./app";
export * from "./common";
// enterprise
export * from "./active-cycle";
export type NestedKeyOf<ObjectType extends object> = {
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
? ObjectType[Key] extends { pop: any; push: any }
? `${Key}`
: `${Key}` | `${Key}.${NestedKeyOf<ObjectType[Key]>}`
: `${Key}`;
}[keyof ObjectType & (string | number)];

View File

@@ -19,7 +19,6 @@ RUN yarn install --network-timeout 500000
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
USER root
ARG NEXT_PUBLIC_API_BASE_URL=""
ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
@@ -32,17 +31,13 @@ RUN yarn turbo run build --filter=space
FROM node:18-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 plane
RUN adduser --system --uid 1001 captain
USER captain
COPY --from=installer /app/space/next.config.js .
COPY --from=installer /app/space/package.json .
COPY --from=installer --chown=captain:plane /app/space/.next/standalone ./
COPY --from=installer /app/space/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/space/.next ./space/.next
COPY --from=installer --chown=captain:plane /app/space/public ./space/public
COPY --from=installer /app/space/.next ./space/.next
COPY --from=installer /app/space/public ./space/public
ARG NEXT_PUBLIC_API_BASE_URL=""
ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
@@ -50,11 +45,9 @@ ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
USER root
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/start.sh
USER captain
ENV NEXT_TELEMETRY_DISABLED 1

View File

@@ -32,7 +32,7 @@ RUN yarn install --network-timeout 500000
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
USER root
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
@@ -46,31 +46,23 @@ RUN yarn turbo run build --filter=web
FROM node:18-alpine AS runner
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 plane
RUN adduser --system --uid 1001 captain
USER captain
COPY --from=installer /app/web/next.config.js .
COPY --from=installer /app/web/package.json .
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=captain:plane /app/web/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/web/.next ./web/.next
COPY --from=installer --chown=captain:plane /app/web/public ./web/public
COPY --from=installer /app/web/.next/standalone ./
COPY --from=installer /app/web/.next ./web/.next
COPY --from=installer /app/web/public ./web/public
ARG NEXT_PUBLIC_API_BASE_URL=""
ARG NEXT_PUBLIC_DEPLOY_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
USER root
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/start.sh
USER captain
ENV NEXT_TELEMETRY_DISABLED 1
EXPOSE 3000

View File

@@ -48,7 +48,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
hideIcon = false,
onChange,
onClose,
placeholder = "Estimate",
placeholder = "",
placement,
projectId,
showTooltip = false,
@@ -197,7 +197,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
variant={buttonVariant}
>
{!hideIcon && <Triangle className="h-3 w-3 flex-shrink-0" />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
{(selectedEstimate || placeholder) && BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">{selectedEstimate !== null ? selectedEstimate : placeholder}</span>
)}
{dropdownArrow && (

View File

@@ -1,16 +1,15 @@
import { FC, useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { Plus } from "lucide-react";
// hooks
import { Plus, RefreshCcw } from "lucide-react";
// ui
import { Breadcrumbs, Button, LayersIcon } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { CreateInboxIssueModal } from "@/components/inbox";
// helper
import { ProjectLogo } from "@/components/project";
import { useProject } from "@/hooks/store";
// hooks
import { useProject, useProjectInbox } from "@/hooks/store";
export const ProjectInboxHeader: FC = observer(() => {
// states
@@ -20,11 +19,12 @@ export const ProjectInboxHeader: FC = observer(() => {
const { workspaceSlug } = router.query;
// store hooks
const { currentProjectDetails } = useProject();
const { isLoading } = useProjectInbox();
return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div>
<div className="flex items-center gap-4">
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
@@ -50,6 +50,13 @@ export const ProjectInboxHeader: FC = observer(() => {
}
/>
</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>

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

View File

@@ -0,0 +1,4 @@
export * from "./root";
export * from "./inbox-issue-header";
export * from "./issue-properties";
export * from "./issue-root";

View File

@@ -1,60 +1,32 @@
import React from "react";
import { observer } from "mobx-react-lite";
import { observer } from "mobx-react";
import { CalendarCheck2, Signal, Tag } from "lucide-react";
// hooks
import { TIssue } from "@plane/types";
import { DoubleCircleIcon, UserGroupIcon } from "@plane/ui";
// components
import { DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui";
import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns";
import { IssueLabel, TIssueOperations } from "@/components/issues";
// icons
// helper
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
issue: Partial<TIssue>;
issueOperations: TIssueOperations;
is_editable: boolean;
};
export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, 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;
export const InboxIssueProperties: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issue, issueOperations, is_editable } = props;
const minDate = issue.start_date ? getDate(issue.start_date) : null;
minDate?.setDate(minDate.getDate());
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
if (!issue || !issue?.id) return <></>;
return (
<div className="flex h-full 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="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">
<div className="flex h-min w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
<div className="h-min w-full overflow-y-auto px-5">
<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="flex flex-col gap-3">
@@ -64,18 +36,22 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
<span>State</span>
</div>
<StateDropdown
value={issue?.state_id ?? undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })}
projectId={projectId?.toString() ?? ""}
disabled={!is_editable}
buttonVariant="transparent-with-text"
className="w-3/5 flex-grow group"
buttonContainerClassName="w-full text-left"
buttonClassName="text-sm"
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
{issue?.state_id && (
<StateDropdown
value={issue?.state_id}
onChange={(val) =>
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { state_id: val })
}
projectId={projectId?.toString() ?? ""}
disabled={!is_editable}
buttonVariant="transparent-with-text"
className="w-3/5 flex-grow group"
buttonContainerClassName="w-full text-left"
buttonClassName="text-sm"
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
)}
</div>
{/* Assignee */}
<div className="flex items-center gap-2 h-8">
@@ -84,17 +60,21 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
<span>Assignees</span>
</div>
<MemberDropdown
value={issue?.assignee_ids ?? undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
value={issue?.assignee_ids ?? []}
onChange={(val) =>
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { assignee_ids: val })
}
disabled={!is_editable}
projectId={projectId?.toString() ?? ""}
placeholder="Add assignees"
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"
buttonContainerClassName="w-full text-left"
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}
dropdownArrow
@@ -108,8 +88,10 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
<span>Priority</span>
</div>
<PriorityDropdown
value={issue?.priority || undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })}
value={issue?.priority || "none"}
onChange={(val) =>
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { priority: val })
}
disabled={!is_editable}
buttonVariant="border-with-text"
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>
<DateDropdown
placeholder="Add due date"
value={issue.target_date}
value={issue.target_date || null}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
issue?.id &&
issueOperations.update(workspaceSlug, projectId, issue?.id, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
@@ -152,16 +135,18 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
<span>Labels</span>
</div>
<div className="w-3/5 flex-grow min-h-8 h-full pt-1">
<IssueLabel
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={!is_editable}
isInboxIssue
onLabelUpdate={(val: string[]) =>
issueOperations.update(workspaceSlug, projectId, issueId, { label_ids: val })
}
/>
{issue?.id && (
<IssueLabel
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issue?.id}
disabled={!is_editable}
isInboxIssue
onLabelUpdate={(val: string[]) =>
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { label_ids: val })
}
/>
)}
</div>
</div>
</div>

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

View File

@@ -1,86 +1,62 @@
import { FC } from "react";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { Inbox } from "lucide-react";
// hooks
import { Loader } from "@plane/ui";
import { InboxIssueActionsHeader } from "@/components/inbox";
import { InboxIssueDetailRoot } from "@/components/issues/issue-detail/inbox";
import { useInboxIssues } from "@/hooks/store";
// components
// ui
import useSWR from "swr";
import { InboxIssueActionsHeader, InboxIssueMainContent } from "@/components/inbox";
import { EUserProjectRoles } from "@/constants/project";
import { useProjectInbox, useUser } from "@/hooks/store";
type TInboxContentRoot = {
workspaceSlug: string;
projectId: string;
inboxId: string;
inboxIssueId: string | undefined;
inboxIssueId: string;
};
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
const { fetchInboxIssueById, getIssueInboxByIssueId } = useProjectInbox();
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
const {
issues: { loader, getInboxIssuesByInboxId },
} = useInboxIssues();
membership: { currentProjectRole },
} = 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 (
<>
{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="flex-shrink-0 min-h-[50px] border-b border-custom-border-300">
<InboxIssueActionsHeader
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxId={inboxId}
inboxIssueId={inboxIssueId}
/>
</div>
<div className="w-full h-full">
<InboxIssueDetailRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxId={inboxId}
issueId={inboxIssueId}
/>
</div>
</div>
)}
</>
)}
<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">
<InboxIssueActionsHeader
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssue={inboxIssue}
isSubmitting={isSubmitting}
/>
</div>
<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">
<InboxIssueMainContent
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssue={inboxIssue}
is_editable={is_editable}
isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting}
/>
</div>
</div>
</>
);
});

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

View File

@@ -0,0 +1,6 @@
export * from "./root";
export * from "./status";
export * from "./priority";
export * from "./member";
export * from "./label";
export * from "./date";

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

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

View File

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

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

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

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

View File

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

View File

@@ -0,0 +1,6 @@
export * from "./filter-selection";
export * from "./status";
export * from "./priority";
export * from "./labels";
export * from "./members";
export * from "./date";

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

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

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

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

View File

@@ -0,0 +1,4 @@
export * from "./root";
export * from "./filters";
export * from "./sorting";
export * from "./applied-filters";

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

View File

@@ -0,0 +1 @@
export * from "./order-by";

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

View File

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

View File

@@ -1,56 +1,45 @@
import React from "react";
import { observer } from "mobx-react";
// hooks
import { INBOX_STATUS } from "@/constants/inbox";
import { useInboxIssues } from "@/hooks/store";
// 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 = {
workspaceSlug: string;
projectId: string;
inboxId: string;
issueId: string;
inboxIssue: IInboxIssueStore;
iconSize?: number;
showDescription?: boolean;
};
export const InboxIssueStatus: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, inboxId, issueId, iconSize = 18, showDescription = false } = props;
// hooks
const {
issues: { getInboxIssueByIssueId },
} = useInboxIssues();
const inboxIssueDetail = getInboxIssueByIssueId(inboxId, issueId);
if (!inboxIssueDetail) return <></>;
const inboxIssueStatusDetail = INBOX_STATUS.find((s) => s.status === inboxIssueDetail.status);
const { inboxIssue, iconSize = 16, showDescription = false } = props;
// derived values
const inboxIssueStatusDetail = INBOX_STATUS.find((s) => s.status === inboxIssue.status);
if (!inboxIssueStatusDetail) return <></>;
const isSnoozedDatePassed =
inboxIssueDetail.status === 0 && !!inboxIssueDetail.snoozed_till && inboxIssueDetail.snoozed_till < new Date();
const isSnoozedDatePassed = inboxIssue.status === 0 && new Date(inboxIssue.snoozed_till ?? "") < new Date();
const description = inboxIssueStatusDetail.description(new Date(inboxIssue.snoozed_till ?? ""));
return (
<div
className={`flex items-center ${inboxIssueStatusDetail.textColor(isSnoozedDatePassed)} ${
showDescription
? `p-3 gap-2 text-sm rounded-md border ${inboxIssueStatusDetail.bgColor(
isSnoozedDatePassed
)} ${inboxIssueStatusDetail.borderColor(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>
className={cn(
`relative flex flex-col gap-1 p-1.5 py-0.5 rounded ${inboxIssueStatusDetail.textColor(
isSnoozedDatePassed
)} ${inboxIssueStatusDetail.bgColor(isSnoozedDatePassed)}`
)}
>
<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>
);
});

View File

@@ -1,14 +1,6 @@
export * from "./root";
export * from "./modals";
export * from "./inbox-issue-actions";
export * from "./sidebar";
export * from "./inbox-filter";
export * from "./content";
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";

View File

@@ -5,11 +5,11 @@ import type { TIssue } from "@plane/types";
// icons
// ui
import { Button } from "@plane/ui";
// types
// hooks
import { useProject } from "@/hooks/store";
type Props = {
data: TIssue;
data: Partial<TIssue>;
isOpen: boolean;
onClose: () => 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">
Are you sure you want to accept issue{" "}
<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>
{""}? Once accepted, this issue will be added to the project issues list.
</p>

View File

@@ -1,24 +1,24 @@
import { Fragment, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { Sparkle } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
import { Transition, Dialog } from "@headlessui/react";
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
// types
import { TIssue } from "@plane/types";
// hooks
// ui
import { Button, Input, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { GptAssistantPopover } from "@/components/core";
import { PriorityDropdown } from "@/components/dropdowns";
// constants
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
import { AIService } from "@/services/ai.service";
import { FileService } from "@/services/file.service";
// components
// ui
// types
// constants
type Props = {
isOpen: boolean;
@@ -26,10 +26,8 @@ type Props = {
};
const defaultValues: Partial<TIssue> = {
project_id: "",
name: "",
description_html: "<p></p>",
parent_id: null,
priority: "none",
};
@@ -39,33 +37,27 @@ const fileService = new FileService();
export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
if (!workspaceSlug || !projectId) return null;
// states
const [createMore, setCreateMore] = useState(false);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
// refs
const editorRef = useRef<any>(null);
// router
const router = useRouter();
const { workspaceSlug, projectId, inboxId } = router.query as {
workspaceSlug: string;
projectId: string;
inboxId: string;
};
// hooks
const { mentionHighlights, mentionSuggestions } = useMention();
const workspaceStore = useWorkspace();
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
// store hooks
const {
issues: { createInboxIssue },
} = useInboxIssues();
const { createInboxIssue } = useProjectInbox();
const {
config: { envConfig },
} = useApplication();
const { captureIssueEvent } = useEventTracker();
// form info
const {
control,
formState: { errors, isSubmitting },
@@ -73,24 +65,26 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
reset,
watch,
getValues,
} = useForm({ defaultValues });
} = useForm<Partial<TIssue>>({ defaultValues });
const issueName = watch("name");
const handleClose = () => {
onClose();
reset(defaultValues);
editorRef?.current?.clearEditor();
};
const issueName = watch("name");
const handleFormSubmit = async (formData: Partial<TIssue>) => {
if (!workspaceSlug || !projectId || !inboxId) return;
await createInboxIssue(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), formData)
if (!workspaceSlug || !projectId) return;
await createInboxIssue(workspaceSlug.toString(), projectId.toString(), formData)
.then((res) => {
if (!createMore) {
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${res.id}`);
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/?inboxIssueId=${res?.issue?.id}`);
handleClose();
} else reset(defaultValues);
} else {
reset(defaultValues);
editorRef?.current?.clearEditor();
}
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: {
@@ -117,11 +111,11 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId) return;
editorRef.current?.setEditorValueAtCursorPosition(response);
};
const handleAutoGenerateDescription = async () => {
const issueName = getValues("name");
if (!workspaceSlug || !projectId || !issueName) return;
setIAmFeelingLucky(true);
@@ -220,7 +214,7 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
</div>
<div className="relative">
<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
type="button"
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 && (
<GptAssistantPopover
isOpen={gptAssistantModal}
projectId={projectId}
projectId={projectId.toString()}
handleClose={() => {
setGptAssistantModal((prevData) => !prevData);
// this is done so that the title do not reset after gpt popover closed

View File

@@ -9,7 +9,7 @@ import { Button } from "@plane/ui";
import { useProject } from "@/hooks/store";
type Props = {
data: TIssue;
data: Partial<TIssue>;
isOpen: boolean;
onClose: () => 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">
Are you sure you want to decline issue{" "}
<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>
{""}? This action cannot be undone.
</p>

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import { observer } from "mobx-react";
import { AlertTriangle } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
// hooks
@@ -11,7 +11,7 @@ import { useProject } from "@/hooks/store";
// types
type Props = {
data: TIssue;
data: Partial<TIssue>;
isOpen: boolean;
onClose: () => void;
onSubmit: () => Promise<void>;
@@ -30,7 +30,7 @@ export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClos
const handleDelete = () => {
setIsDeleting(true);
onSubmit().finally(() => setIsDeleting(false));
onSubmit().finally(() => handleClose());
};
return (
@@ -73,7 +73,8 @@ export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClos
<p className="text-sm text-custom-text-200">
Are you sure you want to delete issue{" "}
<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>
{""}? The issue will only be deleted from the inbox and this action cannot be undone.
</p>

View File

@@ -3,3 +3,4 @@ export * from "./create-issue-modal";
export * from "./decline-issue-modal";
export * from "./delete-issue-modal";
export * from "./select-duplicate";
export * from "./snooze-issue-modal";

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

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

View File

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

View File

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

View File

@@ -1,49 +1,40 @@
import { FC, useEffect } from "react";
import { FC, MouseEvent, useEffect } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useRouter } from "next/router";
// icons
import { CalendarDays } from "lucide-react";
// hooks
// ui
import { Tooltip, PriorityIcon } from "@plane/ui";
// helpers
import { InboxIssueStatus } from "@/components/inbox/inbox-issue-status";
import { renderFormattedDate } from "@/helpers/date-time.helper";
// 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";
// store
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
type TInboxIssueListItem = {
type InboxIssueListItemProps = {
workspaceSlug: string;
projectId: string;
inboxId: string;
issueId: string;
projectIdentifier?: string;
inboxIssue: IInboxIssueStore;
};
export const InboxIssueListItem: FC<TInboxIssueListItem> = observer((props) => {
const { workspaceSlug, projectId, inboxId, issueId } = props;
export const InboxIssueListItem: FC<InboxIssueListItemProps> = observer((props) => {
const { workspaceSlug, projectId, inboxIssue, projectIdentifier } = props;
// router
const router = useRouter();
const { inboxIssueId } = router.query;
// hooks
const { getProjectById } = useProject();
const {
issues: { getInboxIssueByIssueId },
} = useInboxIssues();
const {
issue: { getIssueById },
} = useIssueDetail();
// store
const { projectLabels } = useLabel();
const { isMobile } = usePlatformOS();
const inboxIssueDetail = getInboxIssueByIssueId(inboxId, issueId);
const issue = getIssueById(issueId);
if (!issue || !inboxIssueDetail) return <></>;
const issue = inboxIssue.issue;
useEffect(() => {
if (issueId === inboxIssueId) {
if (issue.id === inboxIssueId) {
setTimeout(() => {
const issueItemCard = document.getElementById(`inbox-issue-list-item-${issueId}`);
const issueItemCard = document.getElementById(`inbox-issue-list-item-${issue.id}`);
if (issueItemCard)
issueItemCard.scrollIntoView({
behavior: "smooth",
@@ -51,52 +42,81 @@ export const InboxIssueListItem: FC<TInboxIssueListItem> = observer((props) => {
});
}, 200);
}
}, [issueId, inboxIssueId]);
}, [inboxIssueId, issue.id]);
const handleIssueRedirection = (event: MouseEvent, currentIssueId: string | undefined) => {
if (inboxIssueId === currentIssueId) event.preventDefault();
};
if (!issue) return <></>;
return (
<>
<Link
id={`inbox-issue-list-item-${issue.id}`}
key={`${inboxId}_${issueId}`}
href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${issueId}`}
key={`${projectId}_${issue.id}`}
href={`/${workspaceSlug}/projects/${projectId}/inbox?inboxIssueId=${issue.id}`}
onClick={(e) => handleIssueRedirection(e, issue.id)}
>
<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 ${
inboxIssueId === issueId ? "bg-custom-primary/5" : " "
} ${inboxIssueDetail.status !== -2 ? "opacity-60" : ""}`}
className={cn(
`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`,
{ "bg-custom-primary/5 border-custom-primary-100 border": inboxIssueId === issue.id }
)}
>
<div className="flex items-center justify-between gap-x-2">
<div className="relative flex items-center gap-x-2 overflow-hidden">
<p className="flex-shrink-0 text-xs text-custom-text-200">
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
</p>
<h5 className="truncate text-sm">{issue.name}</h5>
</div>
<div>
<InboxIssueStatus
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxId={inboxId}
issueId={issueId}
iconSize={14}
/>
<div className="space-y-1">
<div className="relative flex items-center justify-between gap-2">
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
{projectIdentifier}-{issue.sequence_id}
</div>
{inboxIssue.status !== -2 && <InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />}
</div>
<h3 className="truncate w-full text-sm">{issue.name}</h3>
</div>
<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
tooltipHeading="Created on"
tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`}
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">
<CalendarDays size={12} strokeWidth={1.5} />
<span>{renderFormattedDate(issue.created_at ?? "")}</span>
</div>
<div className="text-xs text-custom-text-200">{renderFormattedDate(issue.created_at ?? "")}</div>
</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>
</Link>

View File

@@ -1,33 +1,33 @@
import { FC } from "react";
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
// hooks
import { useInboxIssues } from "@/hooks/store";
// 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) => {
const { workspaceSlug, projectId, inboxId } = props;
// hooks
const {
issues: { getInboxIssuesByInboxId },
} = useInboxIssues();
export const InboxIssueList: FC<InboxIssueListProps> = observer((props) => {
const { workspaceSlug, projectId, projectIdentifier, inboxIssues } = props;
const inboxIssueIds = getInboxIssuesByInboxId(inboxId);
if (!inboxIssueIds) return <></>;
return (
<div className="overflow-y-auto w-full h-full vertical-scrollbar scrollbar-md">
{inboxIssueIds.map((issueId) => (
<InboxIssueListItem
key={issueId}
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxId={inboxId}
issueId={issueId}
/>
<>
{inboxIssues.map((inboxIssue) => (
<Fragment key={inboxIssue.id}>
<InboxIssueListItem
key={inboxIssue.id}
workspaceSlug={workspaceSlug}
projectId={projectId}
projectIdentifier={projectIdentifier}
inboxIssue={inboxIssue}
/>
</Fragment>
))}
</div>
</>
);
});

View File

@@ -0,0 +1,3 @@
export * from "./root";
export * from "./inbox-list";
export * from "./inbox-list-item";

View File

@@ -1,49 +1,143 @@
import { FC } from "react";
import { FC, useCallback, useRef } from "react";
import { observer } from "mobx-react";
import { Inbox } from "lucide-react";
// hooks
import { InboxSidebarLoader } from "@/components/ui";
import { useInboxIssues } from "@/hooks/store";
// ui
import { useRouter } from "next/router";
import { TInboxIssueCurrentTab } from "@plane/types";
import { Loader } from "@plane/ui";
// 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;
projectId: string;
inboxId: string;
};
export const InboxSidebarRoot: FC<TInboxSidebarRoot> = observer((props) => {
const { workspaceSlug, projectId, inboxId } = props;
// store hooks
const {
issues: { loader },
} = useInboxIssues();
const tabNavigationOptions: { key: TInboxIssueCurrentTab; label: string }[] = [
{
key: "open",
label: "Open",
},
{
key: "closed",
label: "Closed",
},
];
if (loader === "init-loader") {
return <InboxSidebarLoader />;
}
export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
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 (
<div className="relative flex flex-col w-full h-full">
<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 flex items-center gap-1">
<div className="relative w-6 h-6 flex justify-center items-center rounded bg-custom-background-80">
<Inbox className="w-4 h-4" />
<div className="flex-shrink-0 w-2/6 h-full border-r border-custom-border-300">
<div className="relative w-full h-full flex flex-col overflow-hidden">
<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">
{tabNavigationOptions.map((option) => (
<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
className={cn(
`border absolute bottom-0 right-0 left-0 rounded-t-md`,
currentTab === option?.key ? `border-custom-primary-100` : `border-transparent`
)}
/>
</div>
))}
<div className="ml-auto">
<FiltersRoot />
</div>
</div>
<div className="z-20">
<InboxIssueFilterSelection workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} />
</div>
</div>
<div className="w-full h-auto">
<InboxIssueAppliedFilter workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} />
</div>
<InboxIssueAppliedFilters />
<div className="w-full h-full overflow-hidden">
<InboxIssueList workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} />
{isLoading && !inboxIssuePaginationInfo?.next_page_results ? (
<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>
);

View File

@@ -3,7 +3,8 @@ export * from "./issue-modal";
export * from "./delete-issue-modal";
export * from "./description-form";
export * from "./issue-layouts";
export * from "./description-input";
export * from "./title-input";
export * from "./parent-issues-list-modal";
export * from "./label";
export * from "./confirm-issue-discard";

View File

@@ -1,3 +0,0 @@
export * from "./root";
export * from "./main-content";
export * from "./sidebar";

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
export * from "./root";
export * from "./main-content";
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 "./label";
export * from "./subscription";
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";

View File

@@ -7,7 +7,6 @@ import { Popover } from "@headlessui/react";
import { IIssueLabel } from "@plane/types";
// hooks
import { Input, TOAST_TYPE, setToast } from "@plane/ui";
import { useIssueDetail } from "@/hooks/store";
// ui
// types
import { TLabelOperations } from "./root";
@@ -16,6 +15,7 @@ type ILabelCreate = {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
labelOperations: TLabelOperations;
disabled?: boolean;
};
@@ -26,11 +26,7 @@ const defaultValues: Partial<IIssueLabel> = {
};
export const LabelCreate: FC<ILabelCreate> = (props) => {
const { workspaceSlug, projectId, issueId, labelOperations, disabled = false } = props;
// hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled = false } = props;
// state
const [isCreateToggle, setIsCreateToggle] = useState(false);
const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle);
@@ -70,9 +66,8 @@ export const LabelCreate: FC<ILabelCreate> = (props) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
try {
const issue = getIssueById(issueId);
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 });
reset(defaultValues);
} catch (error) {

View File

@@ -1,7 +1,7 @@
import { FC } from "react";
import { X } from "lucide-react";
// types
import { useIssueDetail, useLabel } from "@/hooks/store";
import { useLabel } from "@/hooks/store";
import { TLabelOperations } from "./root";
type TLabelListItem = {
@@ -9,24 +9,21 @@ type TLabelListItem = {
projectId: string;
issueId: string;
labelId: string;
values: string[];
labelOperations: TLabelOperations;
disabled: boolean;
};
export const LabelListItem: FC<TLabelListItem> = (props) => {
const { workspaceSlug, projectId, issueId, labelId, labelOperations, disabled } = props;
const { workspaceSlug, projectId, issueId, labelId, values, labelOperations, disabled } = props;
// hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { getLabelById } = useLabel();
const issue = getIssueById(issueId);
const label = getLabelById(labelId);
const handleLabel = async () => {
if (issue && !disabled) {
const currentLabels = issue.label_ids.filter((_labelId) => _labelId !== labelId);
if (values && !disabled) {
const currentLabels = values.filter((_labelId) => _labelId !== labelId);
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
}
};

View File

@@ -1,9 +1,7 @@
import { FC } from "react";
import { observer } from "mobx-react";
// components
import { useIssueDetail } from "@/hooks/store";
import { LabelListItem } from "./label-list-item";
// hooks
// types
import { TLabelOperations } from "./root";
@@ -11,21 +9,16 @@ type TLabelList = {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
labelOperations: TLabelOperations;
disabled: boolean;
};
export const LabelList: FC<TLabelList> = observer((props) => {
const { workspaceSlug, projectId, issueId, labelOperations, disabled } = props;
// hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled } = props;
const issueLabels = values || undefined;
const issue = getIssueById(issueId);
const issueLabels = issue?.label_ids || undefined;
if (!issue || !issueLabels) return <></>;
if (!issueId || !issueLabels) return <></>;
return (
<>
{issueLabels.map((labelId) => (
@@ -35,6 +28,7 @@ export const LabelList: FC<TLabelList> = observer((props) => {
projectId={projectId}
issueId={issueId}
labelId={labelId}
values={issueLabels}
labelOperations={labelOperations}
disabled={disabled}
/>

View File

@@ -4,7 +4,7 @@ import { IIssueLabel, TIssue } from "@plane/types";
// components
import { TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useIssueDetail, useLabel } from "@/hooks/store";
import { useIssueDetail, useLabel, useProjectInbox } from "@/hooks/store";
// ui
// types
import { LabelList, LabelCreate, IssueLabelSelectRoot } from "./";
@@ -28,6 +28,12 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
// hooks
const { updateIssue } = useIssueDetail();
const { createLabel } = useLabel();
const {
issue: { getIssueById },
} = useIssueDetail();
const { getIssueInboxByIssueId } = useProjectInbox();
const issue = isInboxIssue ? getIssueInboxByIssueId(issueId)?.issue : getIssueById(issueId);
const labelOperations: TLabelOperations = useMemo(
() => ({
@@ -72,6 +78,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={issue?.label_ids || []}
labelOperations={labelOperations}
disabled={disabled}
/>
@@ -81,6 +88,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={issue?.label_ids || []}
labelOperations={labelOperations}
/>
)}
@@ -90,6 +98,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={issue?.label_ids || []}
labelOperations={labelOperations}
/>
)}

View File

@@ -4,22 +4,20 @@ import { usePopper } from "react-popper";
import { Check, Search, Tag } from "lucide-react";
import { Combobox } from "@headlessui/react";
// hooks
import { useIssueDetail, useLabel } from "@/hooks/store";
import { useLabel } from "@/hooks/store";
// components
export interface IIssueLabelSelect {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
onSelect: (_labelIds: string[]) => void;
}
export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) => {
const { workspaceSlug, projectId, issueId, onSelect } = props;
const { workspaceSlug, projectId, issueId, values, onSelect } = props;
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { fetchProjectLabels, getProjectLabels } = useLabel();
// states
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 [query, setQuery] = useState("");
const issue = getIssueById(issueId);
const projectLabels = getProjectLabels(projectId);
const fetchLabels = () => {
@@ -67,7 +64,7 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
],
});
const issueLabels = issue?.label_ids ?? [];
const issueLabels = values ?? [];
const label = (
<div
@@ -87,7 +84,7 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
}
};
if (!issue) return <></>;
if (!issueId || !values) return <></>;
return (
<>

View File

@@ -8,17 +8,24 @@ type TIssueLabelSelectRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
labelOperations: TLabelOperations;
};
export const IssueLabelSelectRoot: FC<TIssueLabelSelectRoot> = (props) => {
const { workspaceSlug, projectId, issueId, labelOperations } = props;
const { workspaceSlug, projectId, issueId, values, labelOperations } = props;
const handleLabel = async (_labelIds: string[]) => {
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: _labelIds });
};
return (
<IssueLabelSelect workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} onSelect={handleLabel} />
<IssueLabelSelect
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={values}
onSelect={handleLabel}
/>
);
};

View File

@@ -41,6 +41,7 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
issue &&
issue.project_id &&
issue.id &&
peekIssue?.issueId !== issue.id &&
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
@@ -64,6 +65,7 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
return (
<ControlLink
id={`issue-${issue.id}`}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
target="_blank"
onClick={() => handleIssuePeekOverview(issue)}

View File

@@ -20,6 +20,7 @@ export const IssueGanttBlock: React.FC<Props> = observer((props) => {
const { getProjectStates } = useProjectState();
const {
issue: { getIssueById },
peekIssue,
setPeekIssue,
} = useIssueDetail();
// derived values
@@ -31,11 +32,13 @@ export const IssueGanttBlock: React.FC<Props> = observer((props) => {
workspaceSlug &&
issueDetails &&
!issueDetails.tempId &&
peekIssue?.issueId !== issueDetails.id &&
setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id });
const { isMobile } = usePlatformOS();
return (
<div
id={`issue-${issueId}`}
className="relative flex h-full w-full cursor-pointer items-center rounded"
style={{
backgroundColor: stateDetails?.color,

View File

@@ -163,7 +163,7 @@ export const GanttQuickAddIssueForm: React.FC<IGanttQuickAddIssueForm> = observe
) : (
<button
type="button"
className="sticky bottom-0 z-[1] flex w-full cursor-pointer items-center gap-2 border-t-[1px] border-custom-border-200 bg-custom-background-100 p-3 py-3 text-custom-primary-100"
className="sticky bottom-0 z-[1] flex w-full cursor-pointer items-center gap-2 border-t-[1px] border-custom-border-200 bg-custom-background-100 px-3 pt-2 text-custom-primary-100"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-3.5 w-3.5 stroke-2" />

View File

@@ -47,13 +47,14 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
const {
router: { workspaceSlug },
} = useApplication();
const { setPeekIssue } = useIssueDetail();
const { peekIssue, setPeekIssue } = useIssueDetail();
const handleIssuePeekOverview = (issue: TIssue) =>
workspaceSlug &&
issue &&
issue.project_id &&
issue.id &&
peekIssue?.issueId !== issue.id &&
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
return (
@@ -69,16 +70,17 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
{issue?.is_draft ? (
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
<span>{issue.name}</span>
<span className="pb-1.5">{issue.name}</span>
</Tooltip>
) : (
<ControlLink
id={`issue-${issue.id}`}
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
issue.id
}`}
target="_blank"
onClick={() => handleIssuePeekOverview(issue)}
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100 pb-1.5"
disabled={!!issue?.tempId}
>
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
@@ -138,7 +140,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
>
<div
className={cn(
"rounded border-[0.5px] w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
"rounded border-[0.5px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
{ "hover:cursor-grab": !isDragDisabled },
{ "border-custom-primary-100": snapshot.isDragging },
{ "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id }

View File

@@ -151,7 +151,7 @@ export const KanBanQuickAddIssueForm: React.FC<IKanBanQuickAddIssueForm> = obser
</div>
) : (
<div
className="flex w-full cursor-pointer items-center gap-2 p-3 py-3 text-custom-primary-100"
className="flex w-full cursor-pointer items-center gap-2 p-3 py-1.5 text-custom-primary-100"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-3.5 w-3.5 stroke-2" />

View File

@@ -34,6 +34,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
issue &&
issue.project_id &&
issue.id &&
peekIssue?.issueId !== issue.id &&
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
const issue = issuesMap[issueId];
@@ -71,6 +72,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
</Tooltip>
) : (
<ControlLink
id={`issue-${issue.id}`}
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
issue.id
}`}

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