forked from github/plane
Merge branch 'develop' of github.com:makeplane/plane into preview
This commit is contained in:
commit
5e53279734
@ -210,6 +210,7 @@ class ModuleSerializer(DynamicBaseSerializer):
|
|||||||
"backlog_issues",
|
"backlog_issues",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
|
"archived_at",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ from .workspace.base import (
|
|||||||
WorkSpaceAvailabilityCheckEndpoint,
|
WorkSpaceAvailabilityCheckEndpoint,
|
||||||
UserWorkspaceDashboardEndpoint,
|
UserWorkspaceDashboardEndpoint,
|
||||||
WorkspaceThemeViewSet,
|
WorkspaceThemeViewSet,
|
||||||
ExportWorkspaceUserActivityEndpoint
|
ExportWorkspaceUserActivityEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .workspace.member import (
|
from .workspace.member import (
|
||||||
@ -91,12 +91,14 @@ from .cycle.base import (
|
|||||||
CycleDateCheckEndpoint,
|
CycleDateCheckEndpoint,
|
||||||
CycleFavoriteViewSet,
|
CycleFavoriteViewSet,
|
||||||
TransferCycleIssueEndpoint,
|
TransferCycleIssueEndpoint,
|
||||||
CycleArchiveUnarchiveEndpoint,
|
|
||||||
CycleUserPropertiesEndpoint,
|
CycleUserPropertiesEndpoint,
|
||||||
)
|
)
|
||||||
from .cycle.issue import (
|
from .cycle.issue import (
|
||||||
CycleIssueViewSet,
|
CycleIssueViewSet,
|
||||||
)
|
)
|
||||||
|
from .cycle.archive import (
|
||||||
|
CycleArchiveUnarchiveEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
|
||||||
from .issue.base import (
|
from .issue.base import (
|
||||||
@ -170,7 +172,6 @@ from .module.base import (
|
|||||||
ModuleViewSet,
|
ModuleViewSet,
|
||||||
ModuleLinkViewSet,
|
ModuleLinkViewSet,
|
||||||
ModuleFavoriteViewSet,
|
ModuleFavoriteViewSet,
|
||||||
ModuleArchiveUnarchiveEndpoint,
|
|
||||||
ModuleUserPropertiesEndpoint,
|
ModuleUserPropertiesEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -178,6 +179,10 @@ from .module.issue import (
|
|||||||
ModuleIssueViewSet,
|
ModuleIssueViewSet,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .module.archive import (
|
||||||
|
ModuleArchiveUnarchiveEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
from .api import ApiTokenEndpoint
|
from .api import ApiTokenEndpoint
|
||||||
|
|
||||||
|
|
||||||
|
409
apiserver/plane/app/views/cycle/archive.py
Normal file
409
apiserver/plane/app/views/cycle/archive.py
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
# Django imports
|
||||||
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
from django.db.models import (
|
||||||
|
Case,
|
||||||
|
CharField,
|
||||||
|
Count,
|
||||||
|
Exists,
|
||||||
|
F,
|
||||||
|
Func,
|
||||||
|
OuterRef,
|
||||||
|
Prefetch,
|
||||||
|
Q,
|
||||||
|
UUIDField,
|
||||||
|
Value,
|
||||||
|
When,
|
||||||
|
)
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from plane.app.permissions import ProjectEntityPermission
|
||||||
|
from plane.db.models import (
|
||||||
|
Cycle,
|
||||||
|
CycleFavorite,
|
||||||
|
Issue,
|
||||||
|
Label,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from plane.utils.analytics_plot import burndown_plot
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from .. import BaseAPIView
|
||||||
|
|
||||||
|
|
||||||
|
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||||
|
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
favorite_subquery = CycleFavorite.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
cycle_id=OuterRef("pk"),
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.filter(project_id=self.kwargs.get("project_id"))
|
||||||
|
.filter(archived_at__isnull=False)
|
||||||
|
.filter(
|
||||||
|
project__project_projectmember__member=self.request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
|
)
|
||||||
|
.filter(project__archived_at__isnull=True)
|
||||||
|
.select_related("project", "workspace", "owned_by")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_cycle__issue__assignees",
|
||||||
|
queryset=User.objects.only(
|
||||||
|
"avatar", "first_name", "id"
|
||||||
|
).distinct(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_cycle__issue__labels",
|
||||||
|
queryset=Label.objects.only(
|
||||||
|
"name", "color", "id"
|
||||||
|
).distinct(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(is_favorite=Exists(favorite_subquery))
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Count(
|
||||||
|
"issue_cycle__issue__id",
|
||||||
|
distinct=True,
|
||||||
|
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__id",
|
||||||
|
distinct=True,
|
||||||
|
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__id",
|
||||||
|
distinct=True,
|
||||||
|
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__id",
|
||||||
|
distinct=True,
|
||||||
|
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__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__state__group="backlog",
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
status=Case(
|
||||||
|
When(
|
||||||
|
Q(start_date__lte=timezone.now())
|
||||||
|
& Q(end_date__gte=timezone.now()),
|
||||||
|
then=Value("CURRENT"),
|
||||||
|
),
|
||||||
|
When(
|
||||||
|
start_date__gt=timezone.now(), then=Value("UPCOMING")
|
||||||
|
),
|
||||||
|
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||||
|
When(
|
||||||
|
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||||
|
then=Value("DRAFT"),
|
||||||
|
),
|
||||||
|
default=Value("DRAFT"),
|
||||||
|
output_field=CharField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue_cycle__issue__assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(
|
||||||
|
issue_cycle__issue__assignees__id__isnull=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "name")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, request, slug, project_id, pk=None):
|
||||||
|
if pk is None:
|
||||||
|
queryset = (
|
||||||
|
self.get_queryset()
|
||||||
|
.annotate(
|
||||||
|
total_issues=Count(
|
||||||
|
"issue_cycle",
|
||||||
|
filter=Q(
|
||||||
|
issue_cycle__issue__archived_at__isnull=True,
|
||||||
|
issue_cycle__issue__is_draft=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
# necessary fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"owned_by_id",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
"progress_snapshot",
|
||||||
|
# meta fields
|
||||||
|
"total_issues",
|
||||||
|
"is_favorite",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"assignee_ids",
|
||||||
|
"status",
|
||||||
|
"archived_at",
|
||||||
|
)
|
||||||
|
).order_by("-is_favorite", "-created_at")
|
||||||
|
return Response(queryset, status=status.HTTP_200_OK)
|
||||||
|
else:
|
||||||
|
queryset = (
|
||||||
|
self.get_queryset()
|
||||||
|
.filter(archived_at__isnull=False)
|
||||||
|
.filter(pk=pk)
|
||||||
|
)
|
||||||
|
data = (
|
||||||
|
self.get_queryset()
|
||||||
|
.filter(pk=pk)
|
||||||
|
.annotate(
|
||||||
|
sub_issues=Issue.issue_objects.filter(
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
parent__isnull=False,
|
||||||
|
issue_cycle__cycle_id=pk,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
# necessary fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"owned_by_id",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
"progress_snapshot",
|
||||||
|
"sub_issues",
|
||||||
|
# meta fields
|
||||||
|
"is_favorite",
|
||||||
|
"total_issues",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"assignee_ids",
|
||||||
|
"status",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
queryset = queryset.first()
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
return Response(
|
||||||
|
{"error": "Cycle does not exist"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assignee Distribution
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(first_name=F("assignees__first_name"))
|
||||||
|
.annotate(last_name=F("assignees__last_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.values(
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"assignee_id",
|
||||||
|
"avatar",
|
||||||
|
"display_name",
|
||||||
|
)
|
||||||
|
.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("first_name", "last_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Label Distribution
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_cycle__cycle_id=pk,
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
|
||||||
|
data["distribution"] = {
|
||||||
|
"assignees": assignee_distribution,
|
||||||
|
"labels": label_distribution,
|
||||||
|
"completion_chart": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if queryset.start_date and queryset.end_date:
|
||||||
|
data["distribution"]["completion_chart"] = burndown_plot(
|
||||||
|
queryset=queryset,
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
cycle_id=pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
data,
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request, slug, project_id, cycle_id):
|
||||||
|
cycle = Cycle.objects.get(
|
||||||
|
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
|
||||||
|
if cycle.end_date >= timezone.now().date():
|
||||||
|
return Response(
|
||||||
|
{"error": "Only completed cycles can be archived"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
cycle.archived_at = timezone.now()
|
||||||
|
cycle.save()
|
||||||
|
return Response(
|
||||||
|
{"archived_at": str(cycle.archived_at)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, request, slug, project_id, cycle_id):
|
||||||
|
cycle = Cycle.objects.get(
|
||||||
|
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
cycle.archived_at = None
|
||||||
|
cycle.save()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
@ -1,10 +1,9 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
# Django imports
|
||||||
from django.contrib.postgres.aggregates import ArrayAgg
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
|
||||||
# Django imports
|
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
Case,
|
Case,
|
||||||
CharField,
|
CharField,
|
||||||
@ -25,7 +24,6 @@ from django.utils import timezone
|
|||||||
# Third party imports
|
# Third party imports
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from plane.app.permissions import (
|
from plane.app.permissions import (
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
ProjectLitePermission,
|
ProjectLitePermission,
|
||||||
@ -686,380 +684,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
ProjectEntityPermission,
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
favorite_subquery = CycleFavorite.objects.filter(
|
|
||||||
user=self.request.user,
|
|
||||||
cycle_id=OuterRef("pk"),
|
|
||||||
project_id=self.kwargs.get("project_id"),
|
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
|
||||||
.filter(project_id=self.kwargs.get("project_id"))
|
|
||||||
.filter(archived_at__isnull=False)
|
|
||||||
.filter(
|
|
||||||
project__project_projectmember__member=self.request.user,
|
|
||||||
project__project_projectmember__is_active=True,
|
|
||||||
)
|
|
||||||
.filter(project__archived_at__isnull=True)
|
|
||||||
.select_related("project", "workspace", "owned_by")
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_cycle__issue__assignees",
|
|
||||||
queryset=User.objects.only(
|
|
||||||
"avatar", "first_name", "id"
|
|
||||||
).distinct(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"issue_cycle__issue__labels",
|
|
||||||
queryset=Label.objects.only(
|
|
||||||
"name", "color", "id"
|
|
||||||
).distinct(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(is_favorite=Exists(favorite_subquery))
|
|
||||||
.annotate(
|
|
||||||
total_issues=Count(
|
|
||||||
"issue_cycle__issue__id",
|
|
||||||
distinct=True,
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
completed_issues=Count(
|
|
||||||
"issue_cycle__issue__id",
|
|
||||||
distinct=True,
|
|
||||||
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__id",
|
|
||||||
distinct=True,
|
|
||||||
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__id",
|
|
||||||
distinct=True,
|
|
||||||
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__id",
|
|
||||||
distinct=True,
|
|
||||||
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__id",
|
|
||||||
distinct=True,
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__state__group="backlog",
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
status=Case(
|
|
||||||
When(
|
|
||||||
Q(start_date__lte=timezone.now())
|
|
||||||
& Q(end_date__gte=timezone.now()),
|
|
||||||
then=Value("CURRENT"),
|
|
||||||
),
|
|
||||||
When(
|
|
||||||
start_date__gt=timezone.now(), then=Value("UPCOMING")
|
|
||||||
),
|
|
||||||
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
|
||||||
When(
|
|
||||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
|
||||||
then=Value("DRAFT"),
|
|
||||||
),
|
|
||||||
default=Value("DRAFT"),
|
|
||||||
output_field=CharField(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
assignee_ids=Coalesce(
|
|
||||||
ArrayAgg(
|
|
||||||
"issue_cycle__issue__assignees__id",
|
|
||||||
distinct=True,
|
|
||||||
filter=~Q(
|
|
||||||
issue_cycle__issue__assignees__id__isnull=True
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Value([], output_field=ArrayField(UUIDField())),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by("-is_favorite", "name")
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
|
|
||||||
def get(self, request, slug, project_id, pk=None):
|
|
||||||
if pk is None:
|
|
||||||
queryset = (
|
|
||||||
self.get_queryset()
|
|
||||||
.annotate(
|
|
||||||
total_issues=Count(
|
|
||||||
"issue_cycle",
|
|
||||||
filter=Q(
|
|
||||||
issue_cycle__issue__archived_at__isnull=True,
|
|
||||||
issue_cycle__issue__is_draft=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.values(
|
|
||||||
# necessary fields
|
|
||||||
"id",
|
|
||||||
"workspace_id",
|
|
||||||
"project_id",
|
|
||||||
# model fields
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"start_date",
|
|
||||||
"end_date",
|
|
||||||
"owned_by_id",
|
|
||||||
"view_props",
|
|
||||||
"sort_order",
|
|
||||||
"external_source",
|
|
||||||
"external_id",
|
|
||||||
"progress_snapshot",
|
|
||||||
# meta fields
|
|
||||||
"total_issues",
|
|
||||||
"is_favorite",
|
|
||||||
"cancelled_issues",
|
|
||||||
"completed_issues",
|
|
||||||
"started_issues",
|
|
||||||
"unstarted_issues",
|
|
||||||
"backlog_issues",
|
|
||||||
"assignee_ids",
|
|
||||||
"status",
|
|
||||||
"archived_at",
|
|
||||||
)
|
|
||||||
).order_by("-is_favorite", "-created_at")
|
|
||||||
return Response(queryset, status=status.HTTP_200_OK)
|
|
||||||
else:
|
|
||||||
queryset = (
|
|
||||||
self.get_queryset()
|
|
||||||
.filter(archived_at__isnull=False)
|
|
||||||
.filter(pk=pk)
|
|
||||||
)
|
|
||||||
data = (
|
|
||||||
self.get_queryset()
|
|
||||||
.filter(pk=pk)
|
|
||||||
.annotate(
|
|
||||||
sub_issues=Issue.issue_objects.filter(
|
|
||||||
project_id=self.kwargs.get("project_id"),
|
|
||||||
parent__isnull=False,
|
|
||||||
issue_cycle__cycle_id=pk,
|
|
||||||
)
|
|
||||||
.order_by()
|
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
|
||||||
.values("count")
|
|
||||||
)
|
|
||||||
.values(
|
|
||||||
# necessary fields
|
|
||||||
"id",
|
|
||||||
"workspace_id",
|
|
||||||
"project_id",
|
|
||||||
# model fields
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"start_date",
|
|
||||||
"end_date",
|
|
||||||
"owned_by_id",
|
|
||||||
"view_props",
|
|
||||||
"sort_order",
|
|
||||||
"external_source",
|
|
||||||
"external_id",
|
|
||||||
"progress_snapshot",
|
|
||||||
"sub_issues",
|
|
||||||
# meta fields
|
|
||||||
"is_favorite",
|
|
||||||
"total_issues",
|
|
||||||
"cancelled_issues",
|
|
||||||
"completed_issues",
|
|
||||||
"started_issues",
|
|
||||||
"unstarted_issues",
|
|
||||||
"backlog_issues",
|
|
||||||
"assignee_ids",
|
|
||||||
"status",
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
queryset = queryset.first()
|
|
||||||
|
|
||||||
if data is None:
|
|
||||||
return Response(
|
|
||||||
{"error": "Cycle does not exist"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assignee Distribution
|
|
||||||
assignee_distribution = (
|
|
||||||
Issue.objects.filter(
|
|
||||||
issue_cycle__cycle_id=pk,
|
|
||||||
workspace__slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
)
|
|
||||||
.annotate(first_name=F("assignees__first_name"))
|
|
||||||
.annotate(last_name=F("assignees__last_name"))
|
|
||||||
.annotate(assignee_id=F("assignees__id"))
|
|
||||||
.annotate(avatar=F("assignees__avatar"))
|
|
||||||
.annotate(display_name=F("assignees__display_name"))
|
|
||||||
.values(
|
|
||||||
"first_name",
|
|
||||||
"last_name",
|
|
||||||
"assignee_id",
|
|
||||||
"avatar",
|
|
||||||
"display_name",
|
|
||||||
)
|
|
||||||
.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("first_name", "last_name")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Label Distribution
|
|
||||||
label_distribution = (
|
|
||||||
Issue.objects.filter(
|
|
||||||
issue_cycle__cycle_id=pk,
|
|
||||||
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")
|
|
||||||
)
|
|
||||||
|
|
||||||
data["distribution"] = {
|
|
||||||
"assignees": assignee_distribution,
|
|
||||||
"labels": label_distribution,
|
|
||||||
"completion_chart": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
if queryset.start_date and queryset.end_date:
|
|
||||||
data["distribution"]["completion_chart"] = burndown_plot(
|
|
||||||
queryset=queryset,
|
|
||||||
slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
cycle_id=pk,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
data,
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
def post(self, request, slug, project_id, cycle_id):
|
|
||||||
cycle = Cycle.objects.get(
|
|
||||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
|
||||||
)
|
|
||||||
|
|
||||||
if cycle.end_date >= timezone.now().date():
|
|
||||||
return Response(
|
|
||||||
{"error": "Only completed cycles can be archived"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
cycle.archived_at = timezone.now()
|
|
||||||
cycle.save()
|
|
||||||
return Response(
|
|
||||||
{"archived_at": str(cycle.archived_at)},
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete(self, request, slug, project_id, cycle_id):
|
|
||||||
cycle = Cycle.objects.get(
|
|
||||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
|
||||||
)
|
|
||||||
cycle.archived_at = None
|
|
||||||
cycle.save()
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
class CycleDateCheckEndpoint(BaseAPIView):
|
class CycleDateCheckEndpoint(BaseAPIView):
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
|
@ -571,14 +571,16 @@ def dashboard_recent_collaborators(self, request, slug):
|
|||||||
return self.paginate(
|
return self.paginate(
|
||||||
request=request,
|
request=request,
|
||||||
queryset=project_members_with_activities,
|
queryset=project_members_with_activities,
|
||||||
controller=self.get_results_controller,
|
controller=lambda qs: self.get_results_controller(qs, slug),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DashboardEndpoint(BaseAPIView):
|
class DashboardEndpoint(BaseAPIView):
|
||||||
def get_results_controller(self, project_members_with_activities):
|
def get_results_controller(self, project_members_with_activities, slug):
|
||||||
user_active_issue_counts = (
|
user_active_issue_counts = (
|
||||||
User.objects.filter(id__in=project_members_with_activities)
|
User.objects.filter(
|
||||||
|
id__in=project_members_with_activities,
|
||||||
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
active_issue_count=Count(
|
active_issue_count=Count(
|
||||||
Case(
|
Case(
|
||||||
@ -587,10 +589,13 @@ class DashboardEndpoint(BaseAPIView):
|
|||||||
"unstarted",
|
"unstarted",
|
||||||
"started",
|
"started",
|
||||||
],
|
],
|
||||||
then=1,
|
issue_assignee__issue__workspace__slug=slug,
|
||||||
|
issue_assignee__issue__project__project_projectmember__is_active=True,
|
||||||
|
then=F("issue_assignee__issue__id"),
|
||||||
),
|
),
|
||||||
output_field=IntegerField(),
|
output_field=IntegerField(),
|
||||||
)
|
),
|
||||||
|
distinct=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.values("active_issue_count", user_id=F("id"))
|
.values("active_issue_count", user_id=F("id"))
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
# Django imports
|
||||||
from django.contrib.postgres.aggregates import ArrayAgg
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
@ -19,14 +20,12 @@ from django.db.models import (
|
|||||||
When,
|
When,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
# Django imports
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.gzip import gzip_page
|
from django.views.decorators.gzip import gzip_page
|
||||||
from rest_framework import status
|
|
||||||
|
|
||||||
# Third Party imports
|
# Third Party imports
|
||||||
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from plane.app.permissions import ProjectEntityPermission
|
from plane.app.permissions import ProjectEntityPermission
|
||||||
|
356
apiserver/plane/app/views/module/archive.py
Normal file
356
apiserver/plane/app/views/module/archive.py
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
from django.db.models import (
|
||||||
|
Count,
|
||||||
|
Exists,
|
||||||
|
F,
|
||||||
|
Func,
|
||||||
|
IntegerField,
|
||||||
|
OuterRef,
|
||||||
|
Prefetch,
|
||||||
|
Q,
|
||||||
|
Subquery,
|
||||||
|
UUIDField,
|
||||||
|
Value,
|
||||||
|
)
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from plane.app.permissions import (
|
||||||
|
ProjectEntityPermission,
|
||||||
|
)
|
||||||
|
from plane.app.serializers import (
|
||||||
|
ModuleDetailSerializer,
|
||||||
|
)
|
||||||
|
from plane.db.models import (
|
||||||
|
Issue,
|
||||||
|
Module,
|
||||||
|
ModuleFavorite,
|
||||||
|
ModuleLink,
|
||||||
|
)
|
||||||
|
from plane.utils.analytics_plot import burndown_plot
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from .. import BaseAPIView
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||||
|
|
||||||
|
permission_classes = [
|
||||||
|
ProjectEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
favorite_subquery = ModuleFavorite.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
module_id=OuterRef("pk"),
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
)
|
||||||
|
cancelled_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="cancelled",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
completed_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="completed",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
started_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="started",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
unstarted_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="unstarted",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
backlog_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
state__group="backlog",
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
total_issues = (
|
||||||
|
Issue.issue_objects.filter(
|
||||||
|
issue_module__module_id=OuterRef("pk"),
|
||||||
|
)
|
||||||
|
.values("issue_module__module_id")
|
||||||
|
.annotate(cnt=Count("pk"))
|
||||||
|
.values("cnt")
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
.filter(archived_at__isnull=False)
|
||||||
|
.annotate(is_favorite=Exists(favorite_subquery))
|
||||||
|
.select_related("workspace", "project", "lead")
|
||||||
|
.prefetch_related("members")
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"link_module",
|
||||||
|
queryset=ModuleLink.objects.select_related(
|
||||||
|
"module", "created_by"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
completed_issues=Coalesce(
|
||||||
|
Subquery(completed_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
cancelled_issues=Coalesce(
|
||||||
|
Subquery(cancelled_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_issues=Coalesce(
|
||||||
|
Subquery(started_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
unstarted_issues=Coalesce(
|
||||||
|
Subquery(unstarted_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
backlog_issues=Coalesce(
|
||||||
|
Subquery(backlog_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_issues=Coalesce(
|
||||||
|
Subquery(total_issues[:1]),
|
||||||
|
Value(0, output_field=IntegerField()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
member_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"members__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=~Q(members__id__isnull=True),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by("-is_favorite", "-created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, request, slug, project_id, pk=None):
|
||||||
|
if pk is None:
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
modules = queryset.values( # Required fields
|
||||||
|
"id",
|
||||||
|
"workspace_id",
|
||||||
|
"project_id",
|
||||||
|
# Model fields
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"description_text",
|
||||||
|
"description_html",
|
||||||
|
"start_date",
|
||||||
|
"target_date",
|
||||||
|
"status",
|
||||||
|
"lead_id",
|
||||||
|
"member_ids",
|
||||||
|
"view_props",
|
||||||
|
"sort_order",
|
||||||
|
"external_source",
|
||||||
|
"external_id",
|
||||||
|
# computed fields
|
||||||
|
"total_issues",
|
||||||
|
"is_favorite",
|
||||||
|
"cancelled_issues",
|
||||||
|
"completed_issues",
|
||||||
|
"started_issues",
|
||||||
|
"unstarted_issues",
|
||||||
|
"backlog_issues",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"archived_at",
|
||||||
|
)
|
||||||
|
return Response(modules, status=status.HTTP_200_OK)
|
||||||
|
else:
|
||||||
|
queryset = (
|
||||||
|
self.get_queryset()
|
||||||
|
.filter(pk=pk)
|
||||||
|
.annotate(
|
||||||
|
sub_issues=Issue.issue_objects.filter(
|
||||||
|
project_id=self.kwargs.get("project_id"),
|
||||||
|
parent__isnull=False,
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assignee_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
.annotate(first_name=F("assignees__first_name"))
|
||||||
|
.annotate(last_name=F("assignees__last_name"))
|
||||||
|
.annotate(assignee_id=F("assignees__id"))
|
||||||
|
.annotate(display_name=F("assignees__display_name"))
|
||||||
|
.annotate(avatar=F("assignees__avatar"))
|
||||||
|
.values(
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"assignee_id",
|
||||||
|
"avatar",
|
||||||
|
"display_name",
|
||||||
|
)
|
||||||
|
.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("first_name", "last_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
label_distribution = (
|
||||||
|
Issue.objects.filter(
|
||||||
|
issue_module__module_id=pk,
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
|
||||||
|
data = ModuleDetailSerializer(queryset.first()).data
|
||||||
|
data["distribution"] = {
|
||||||
|
"assignees": assignee_distribution,
|
||||||
|
"labels": label_distribution,
|
||||||
|
"completion_chart": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch the modules
|
||||||
|
modules = queryset.first()
|
||||||
|
if modules and modules.start_date and modules.target_date:
|
||||||
|
data["distribution"]["completion_chart"] = burndown_plot(
|
||||||
|
queryset=modules,
|
||||||
|
slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
module_id=pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
data,
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request, slug, project_id, module_id):
|
||||||
|
module = Module.objects.get(
|
||||||
|
pk=module_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
if module.status not in ["completed", "cancelled"]:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "Only completed or cancelled modules can be archived"
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
module.archived_at = timezone.now()
|
||||||
|
module.save()
|
||||||
|
return Response(
|
||||||
|
{"archived_at": str(module.archived_at)},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, request, slug, project_id, module_id):
|
||||||
|
module = Module.objects.get(
|
||||||
|
pk=module_id, project_id=project_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
module.archived_at = None
|
||||||
|
module.save()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
@ -515,325 +515,6 @@ class ModuleLinkViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
ProjectEntityPermission,
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
favorite_subquery = ModuleFavorite.objects.filter(
|
|
||||||
user=self.request.user,
|
|
||||||
module_id=OuterRef("pk"),
|
|
||||||
project_id=self.kwargs.get("project_id"),
|
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
|
||||||
)
|
|
||||||
cancelled_issues = (
|
|
||||||
Issue.issue_objects.filter(
|
|
||||||
state__group="cancelled",
|
|
||||||
issue_module__module_id=OuterRef("pk"),
|
|
||||||
)
|
|
||||||
.values("issue_module__module_id")
|
|
||||||
.annotate(cnt=Count("pk"))
|
|
||||||
.values("cnt")
|
|
||||||
)
|
|
||||||
completed_issues = (
|
|
||||||
Issue.issue_objects.filter(
|
|
||||||
state__group="completed",
|
|
||||||
issue_module__module_id=OuterRef("pk"),
|
|
||||||
)
|
|
||||||
.values("issue_module__module_id")
|
|
||||||
.annotate(cnt=Count("pk"))
|
|
||||||
.values("cnt")
|
|
||||||
)
|
|
||||||
started_issues = (
|
|
||||||
Issue.issue_objects.filter(
|
|
||||||
state__group="started",
|
|
||||||
issue_module__module_id=OuterRef("pk"),
|
|
||||||
)
|
|
||||||
.values("issue_module__module_id")
|
|
||||||
.annotate(cnt=Count("pk"))
|
|
||||||
.values("cnt")
|
|
||||||
)
|
|
||||||
unstarted_issues = (
|
|
||||||
Issue.issue_objects.filter(
|
|
||||||
state__group="unstarted",
|
|
||||||
issue_module__module_id=OuterRef("pk"),
|
|
||||||
)
|
|
||||||
.values("issue_module__module_id")
|
|
||||||
.annotate(cnt=Count("pk"))
|
|
||||||
.values("cnt")
|
|
||||||
)
|
|
||||||
backlog_issues = (
|
|
||||||
Issue.issue_objects.filter(
|
|
||||||
state__group="backlog",
|
|
||||||
issue_module__module_id=OuterRef("pk"),
|
|
||||||
)
|
|
||||||
.values("issue_module__module_id")
|
|
||||||
.annotate(cnt=Count("pk"))
|
|
||||||
.values("cnt")
|
|
||||||
)
|
|
||||||
total_issues = (
|
|
||||||
Issue.issue_objects.filter(
|
|
||||||
issue_module__module_id=OuterRef("pk"),
|
|
||||||
)
|
|
||||||
.values("issue_module__module_id")
|
|
||||||
.annotate(cnt=Count("pk"))
|
|
||||||
.values("cnt")
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
|
|
||||||
.filter(archived_at__isnull=False)
|
|
||||||
.annotate(is_favorite=Exists(favorite_subquery))
|
|
||||||
.select_related("workspace", "project", "lead")
|
|
||||||
.prefetch_related("members")
|
|
||||||
.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
"link_module",
|
|
||||||
queryset=ModuleLink.objects.select_related(
|
|
||||||
"module", "created_by"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
completed_issues=Coalesce(
|
|
||||||
Subquery(completed_issues[:1]),
|
|
||||||
Value(0, output_field=IntegerField()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
cancelled_issues=Coalesce(
|
|
||||||
Subquery(cancelled_issues[:1]),
|
|
||||||
Value(0, output_field=IntegerField()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
started_issues=Coalesce(
|
|
||||||
Subquery(started_issues[:1]),
|
|
||||||
Value(0, output_field=IntegerField()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
unstarted_issues=Coalesce(
|
|
||||||
Subquery(unstarted_issues[:1]),
|
|
||||||
Value(0, output_field=IntegerField()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
backlog_issues=Coalesce(
|
|
||||||
Subquery(backlog_issues[:1]),
|
|
||||||
Value(0, output_field=IntegerField()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
total_issues=Coalesce(
|
|
||||||
Subquery(total_issues[:1]),
|
|
||||||
Value(0, output_field=IntegerField()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.annotate(
|
|
||||||
member_ids=Coalesce(
|
|
||||||
ArrayAgg(
|
|
||||||
"members__id",
|
|
||||||
distinct=True,
|
|
||||||
filter=~Q(members__id__isnull=True),
|
|
||||||
),
|
|
||||||
Value([], output_field=ArrayField(UUIDField())),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by("-is_favorite", "-created_at")
|
|
||||||
)
|
|
||||||
|
|
||||||
def get(self, request, slug, project_id, pk=None):
|
|
||||||
if pk is None:
|
|
||||||
queryset = self.get_queryset()
|
|
||||||
modules = queryset.values( # Required fields
|
|
||||||
"id",
|
|
||||||
"workspace_id",
|
|
||||||
"project_id",
|
|
||||||
# Model fields
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"description_text",
|
|
||||||
"description_html",
|
|
||||||
"start_date",
|
|
||||||
"target_date",
|
|
||||||
"status",
|
|
||||||
"lead_id",
|
|
||||||
"member_ids",
|
|
||||||
"view_props",
|
|
||||||
"sort_order",
|
|
||||||
"external_source",
|
|
||||||
"external_id",
|
|
||||||
# computed fields
|
|
||||||
"total_issues",
|
|
||||||
"is_favorite",
|
|
||||||
"cancelled_issues",
|
|
||||||
"completed_issues",
|
|
||||||
"started_issues",
|
|
||||||
"unstarted_issues",
|
|
||||||
"backlog_issues",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
"archived_at",
|
|
||||||
)
|
|
||||||
return Response(modules, status=status.HTTP_200_OK)
|
|
||||||
else:
|
|
||||||
queryset = (
|
|
||||||
self.get_queryset()
|
|
||||||
.filter(pk=pk)
|
|
||||||
.annotate(
|
|
||||||
sub_issues=Issue.issue_objects.filter(
|
|
||||||
project_id=self.kwargs.get("project_id"),
|
|
||||||
parent__isnull=False,
|
|
||||||
issue_module__module_id=pk,
|
|
||||||
)
|
|
||||||
.order_by()
|
|
||||||
.annotate(count=Func(F("id"), function="Count"))
|
|
||||||
.values("count")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assignee_distribution = (
|
|
||||||
Issue.objects.filter(
|
|
||||||
issue_module__module_id=pk,
|
|
||||||
workspace__slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
)
|
|
||||||
.annotate(first_name=F("assignees__first_name"))
|
|
||||||
.annotate(last_name=F("assignees__last_name"))
|
|
||||||
.annotate(assignee_id=F("assignees__id"))
|
|
||||||
.annotate(display_name=F("assignees__display_name"))
|
|
||||||
.annotate(avatar=F("assignees__avatar"))
|
|
||||||
.values(
|
|
||||||
"first_name",
|
|
||||||
"last_name",
|
|
||||||
"assignee_id",
|
|
||||||
"avatar",
|
|
||||||
"display_name",
|
|
||||||
)
|
|
||||||
.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("first_name", "last_name")
|
|
||||||
)
|
|
||||||
|
|
||||||
label_distribution = (
|
|
||||||
Issue.objects.filter(
|
|
||||||
issue_module__module_id=pk,
|
|
||||||
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")
|
|
||||||
)
|
|
||||||
|
|
||||||
data = ModuleDetailSerializer(queryset.first()).data
|
|
||||||
data["distribution"] = {
|
|
||||||
"assignees": assignee_distribution,
|
|
||||||
"labels": label_distribution,
|
|
||||||
"completion_chart": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fetch the modules
|
|
||||||
modules = queryset.first()
|
|
||||||
if modules and modules.start_date and modules.target_date:
|
|
||||||
data["distribution"]["completion_chart"] = burndown_plot(
|
|
||||||
queryset=modules,
|
|
||||||
slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
module_id=pk,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
data,
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
def post(self, request, slug, project_id, module_id):
|
|
||||||
module = Module.objects.get(
|
|
||||||
pk=module_id, project_id=project_id, workspace__slug=slug
|
|
||||||
)
|
|
||||||
if module.status not in ["completed", "cancelled"]:
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"error": "Only completed or cancelled modules can be archived"
|
|
||||||
},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
module.archived_at = timezone.now()
|
|
||||||
module.save()
|
|
||||||
return Response(
|
|
||||||
{"archived_at": str(module.archived_at)},
|
|
||||||
status=status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete(self, request, slug, project_id, module_id):
|
|
||||||
module = Module.objects.get(
|
|
||||||
pk=module_id, project_id=project_id, workspace__slug=slug
|
|
||||||
)
|
|
||||||
module.archived_at = None
|
|
||||||
module.save()
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
class ModuleFavoriteViewSet(BaseViewSet):
|
class ModuleFavoriteViewSet(BaseViewSet):
|
||||||
serializer_class = ModuleFavoriteSerializer
|
serializer_class = ModuleFavoriteSerializer
|
||||||
model = ModuleFavorite
|
model = ModuleFavorite
|
||||||
|
@ -49,7 +49,12 @@ class UserEndpoint(BaseViewSet):
|
|||||||
{"is_instance_admin": is_admin}, status=status.HTTP_200_OK
|
{"is_instance_admin": is_admin}, status=status.HTTP_200_OK
|
||||||
)
|
)
|
||||||
|
|
||||||
@invalidate_cache(path="/api/users/me/")
|
@invalidate_cache(
|
||||||
|
path="/api/users/me/",
|
||||||
|
)
|
||||||
|
@invalidate_cache(
|
||||||
|
path="/api/users/me/settings/",
|
||||||
|
)
|
||||||
def partial_update(self, request, *args, **kwargs):
|
def partial_update(self, request, *args, **kwargs):
|
||||||
return super().partial_update(request, *args, **kwargs)
|
return super().partial_update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ class Command(BaseCommand):
|
|||||||
receiver_email = options.get("to_email")
|
receiver_email = options.get("to_email")
|
||||||
|
|
||||||
if not receiver_email:
|
if not receiver_email:
|
||||||
raise CommandError("Reciever email is required")
|
raise CommandError("Receiver email is required")
|
||||||
|
|
||||||
(
|
(
|
||||||
EMAIL_HOST,
|
EMAIL_HOST,
|
||||||
@ -54,7 +54,7 @@ class Command(BaseCommand):
|
|||||||
connection=connection,
|
connection=connection,
|
||||||
)
|
)
|
||||||
msg.send()
|
msg.send()
|
||||||
self.stdout.write(self.style.SUCCESS("Email succesfully sent"))
|
self.stdout.write(self.style.SUCCESS("Email successfully sent"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.ERROR(
|
self.style.ERROR(
|
||||||
|
@ -11,17 +11,7 @@ export interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextArea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, ref) => {
|
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, ref) => {
|
||||||
const {
|
const { id, name, value = "", mode = "primary", hasError = false, className = "", ...rest } = props;
|
||||||
id,
|
|
||||||
name,
|
|
||||||
value = "",
|
|
||||||
rows = 1,
|
|
||||||
cols = 1,
|
|
||||||
mode = "primary",
|
|
||||||
hasError = false,
|
|
||||||
className = "",
|
|
||||||
...rest
|
|
||||||
} = props;
|
|
||||||
// refs
|
// refs
|
||||||
const textAreaRef = useRef<any>(ref);
|
const textAreaRef = useRef<any>(ref);
|
||||||
// auto re-size
|
// auto re-size
|
||||||
@ -33,8 +23,6 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, re
|
|||||||
name={name}
|
name={name}
|
||||||
ref={textAreaRef}
|
ref={textAreaRef}
|
||||||
value={value}
|
value={value}
|
||||||
rows={rows}
|
|
||||||
cols={cols}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"no-scrollbar w-full bg-transparent px-3 py-2 placeholder-custom-text-400 outline-none",
|
"no-scrollbar w-full bg-transparent px-3 py-2 placeholder-custom-text-400 outline-none",
|
||||||
{
|
{
|
||||||
|
@ -34,10 +34,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
|||||||
return !!ref && typeof ref === "object" && "current" in ref;
|
return !!ref && typeof ref === "object" && "current" in ref;
|
||||||
}
|
}
|
||||||
const isEmpty =
|
const isEmpty =
|
||||||
props.initialValue === "" ||
|
|
||||||
props.initialValue?.trim() === "" ||
|
props.initialValue?.trim() === "" ||
|
||||||
props.initialValue === "<p></p>" ||
|
props.initialValue === "<p></p>" ||
|
||||||
isEmptyHtmlString(props.initialValue ?? "");
|
(isEmptyHtmlString(props.initialValue ?? "") && !props.initialValue?.includes("mention-component"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-custom-border-200 rounded p-3 space-y-3">
|
<div className="border border-custom-border-200 rounded p-3 space-y-3">
|
||||||
|
@ -17,6 +17,7 @@ import {
|
|||||||
SignalMediumIcon,
|
SignalMediumIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
|
Inbox,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IIssueActivity } from "@plane/types";
|
import { IIssueActivity } from "@plane/types";
|
||||||
import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon } from "@plane/ui";
|
import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon } from "@plane/ui";
|
||||||
@ -112,6 +113,40 @@ const EstimatePoint = observer((props: { point: string }) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const inboxActivityMessage = {
|
||||||
|
declined: {
|
||||||
|
showIssue: "declined issue",
|
||||||
|
noIssue: "declined this issue from inbox.",
|
||||||
|
},
|
||||||
|
snoozed: {
|
||||||
|
showIssue: "snoozed issue",
|
||||||
|
noIssue: "snoozed this issue.",
|
||||||
|
},
|
||||||
|
accepted: {
|
||||||
|
showIssue: "accepted issue",
|
||||||
|
noIssue: "accepted this issue from inbox.",
|
||||||
|
},
|
||||||
|
markedDuplicate: {
|
||||||
|
showIssue: "declined issue",
|
||||||
|
noIssue: "declined this issue from inbox by marking a duplicate issue.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInboxUserActivityMessage = (activity: IIssueActivity, showIssue: boolean) => {
|
||||||
|
switch (activity.verb) {
|
||||||
|
case "-1":
|
||||||
|
return showIssue ? inboxActivityMessage.declined.showIssue : inboxActivityMessage.declined.noIssue;
|
||||||
|
case "0":
|
||||||
|
return showIssue ? inboxActivityMessage.snoozed.showIssue : inboxActivityMessage.snoozed.noIssue;
|
||||||
|
case "1":
|
||||||
|
return showIssue ? inboxActivityMessage.accepted.showIssue : inboxActivityMessage.accepted.noIssue;
|
||||||
|
case "2":
|
||||||
|
return showIssue ? inboxActivityMessage.markedDuplicate.showIssue : inboxActivityMessage.markedDuplicate.noIssue;
|
||||||
|
default:
|
||||||
|
return "updated inbox issue status.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const activityDetails: {
|
const activityDetails: {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
message: (activity: IIssueActivity, showIssue: boolean, workspaceSlug: string) => React.ReactNode;
|
message: (activity: IIssueActivity, showIssue: boolean, workspaceSlug: string) => React.ReactNode;
|
||||||
@ -658,8 +693,7 @@ const activityDetails: {
|
|||||||
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
|
<span className="font-medium text-custom-text-100">{renderFormattedDate(activity.new_value)}</span>
|
||||||
{showIssue && (
|
{showIssue && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
<IssueLink activity={activity} />
|
||||||
for <IssueLink activity={activity} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -667,6 +701,20 @@ const activityDetails: {
|
|||||||
},
|
},
|
||||||
icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
|
icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
|
inbox: {
|
||||||
|
message: (activity, showIssue) => (
|
||||||
|
<>
|
||||||
|
{getInboxUserActivityMessage(activity, showIssue)}
|
||||||
|
{showIssue && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<IssueLink activity={activity} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
icon: <Inbox size={12} color="#6b7280" aria-hidden="true" />,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ActivityIcon = ({ activity }: { activity: IIssueActivity }) => (
|
export const ActivityIcon = ({ activity }: { activity: IIssueActivity }) => (
|
||||||
|
@ -34,12 +34,12 @@ export const ArchivedCyclesHeader: FC = observer(() => {
|
|||||||
const handleFilters = useCallback(
|
const handleFilters = useCallback(
|
||||||
(key: keyof TCycleFilters, value: string | string[]) => {
|
(key: keyof TCycleFilters, value: string | string[]) => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
const newValues = currentProjectArchivedFilters?.[key] ?? [];
|
const newValues = currentProjectArchivedFilters?.[key] ?? [];
|
||||||
|
|
||||||
if (Array.isArray(value))
|
if (Array.isArray(value))
|
||||||
value.forEach((val) => {
|
value.forEach((val) => {
|
||||||
if (!newValues.includes(val)) newValues.push(val);
|
if (!newValues.includes(val)) newValues.push(val);
|
||||||
|
else newValues.splice(newValues.indexOf(val), 1);
|
||||||
});
|
});
|
||||||
else {
|
else {
|
||||||
if (currentProjectArchivedFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
if (currentProjectArchivedFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||||
|
@ -47,11 +47,13 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
const handleFilters = useCallback(
|
const handleFilters = useCallback(
|
||||||
(key: keyof TCycleFilters, value: string | string[]) => {
|
(key: keyof TCycleFilters, value: string | string[]) => {
|
||||||
|
if (!projectId) return;
|
||||||
const newValues = currentProjectFilters?.[key] ?? [];
|
const newValues = currentProjectFilters?.[key] ?? [];
|
||||||
|
|
||||||
if (Array.isArray(value))
|
if (Array.isArray(value))
|
||||||
value.forEach((val) => {
|
value.forEach((val) => {
|
||||||
if (!newValues.includes(val)) newValues.push(val);
|
if (!newValues.includes(val)) newValues.push(val);
|
||||||
|
else newValues.splice(newValues.indexOf(val), 1);
|
||||||
});
|
});
|
||||||
else {
|
else {
|
||||||
if (currentProjectFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
if (currentProjectFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||||
|
@ -6,6 +6,8 @@ import { DateFilterModal } from "@/components/core";
|
|||||||
import { FilterHeader, FilterOption } from "@/components/issues";
|
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||||
// constants
|
// constants
|
||||||
import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters";
|
import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters";
|
||||||
|
// helpers
|
||||||
|
import { isInDateFormat } from "@/helpers/date-time.helper";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appliedFilters: string[] | null;
|
appliedFilters: string[] | null;
|
||||||
@ -25,6 +27,17 @@ export const FilterEndDate: React.FC<Props> = observer((props) => {
|
|||||||
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isCustomDateSelected = () => {
|
||||||
|
const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || [];
|
||||||
|
return isValidDateSelected.length > 0 ? true : false;
|
||||||
|
};
|
||||||
|
const handleCustomDate = () => {
|
||||||
|
if (isCustomDateSelected()) {
|
||||||
|
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
|
||||||
|
handleUpdate(updateAppliedFilters);
|
||||||
|
} else setIsDateFilterModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDateFilterModalOpen && (
|
{isDateFilterModalOpen && (
|
||||||
@ -53,7 +66,7 @@ export const FilterEndDate: React.FC<Props> = observer((props) => {
|
|||||||
multiple
|
multiple
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple />
|
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||||
|
@ -6,6 +6,8 @@ import { DateFilterModal } from "@/components/core";
|
|||||||
import { FilterHeader, FilterOption } from "@/components/issues";
|
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||||
// constants
|
// constants
|
||||||
import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters";
|
import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters";
|
||||||
|
// helpers
|
||||||
|
import { isInDateFormat } from "@/helpers/date-time.helper";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appliedFilters: string[] | null;
|
appliedFilters: string[] | null;
|
||||||
@ -25,6 +27,17 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
|
|||||||
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isCustomDateSelected = () => {
|
||||||
|
const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || [];
|
||||||
|
return isValidDateSelected.length > 0 ? true : false;
|
||||||
|
};
|
||||||
|
const handleCustomDate = () => {
|
||||||
|
if (isCustomDateSelected()) {
|
||||||
|
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
|
||||||
|
handleUpdate(updateAppliedFilters);
|
||||||
|
} else setIsDateFilterModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDateFilterModalOpen && (
|
{isDateFilterModalOpen && (
|
||||||
@ -53,7 +66,7 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
|
|||||||
multiple
|
multiple
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple />
|
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||||
|
@ -417,7 +417,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
|||||||
<ButtonToRender
|
<ButtonToRender
|
||||||
priority={value}
|
priority={value}
|
||||||
className={cn(buttonClassName, {
|
className={cn(buttonClassName, {
|
||||||
"text-white": resolvedTheme === "dark",
|
"text-custom-text-200": resolvedTheme?.includes("dark") || resolvedTheme === "custom",
|
||||||
})}
|
})}
|
||||||
highlightUrgent={highlightUrgent}
|
highlightUrgent={highlightUrgent}
|
||||||
dropdownArrow={dropdownArrow && !disabled}
|
dropdownArrow={dropdownArrow && !disabled}
|
||||||
|
@ -176,7 +176,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||||
<span className="flex-grow truncate">{selectedProject?.name ?? placeholder}</span>
|
<span className="flex-grow truncate max-w-40">{selectedProject?.name ?? placeholder}</span>
|
||||||
)}
|
)}
|
||||||
{dropdownArrow && (
|
{dropdownArrow && (
|
||||||
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
||||||
|
@ -59,10 +59,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isEmpty =
|
const isEmpty =
|
||||||
props.initialValue === "" ||
|
|
||||||
props.initialValue?.trim() === "" ||
|
props.initialValue?.trim() === "" ||
|
||||||
props.initialValue === "<p></p>" ||
|
props.initialValue === "<p></p>" ||
|
||||||
isEmptyHtmlString(props.initialValue ?? "");
|
(isEmptyHtmlString(props.initialValue ?? "") && !props.initialValue?.includes("mention-component"));
|
||||||
|
|
||||||
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
|
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
|
||||||
return !!ref && typeof ref === "object" && "current" in ref;
|
return !!ref && typeof ref === "object" && "current" in ref;
|
||||||
|
@ -62,6 +62,7 @@ export const ModulesListHeader: React.FC = observer(() => {
|
|||||||
if (Array.isArray(value))
|
if (Array.isArray(value))
|
||||||
value.forEach((val) => {
|
value.forEach((val) => {
|
||||||
if (!newValues.includes(val)) newValues.push(val);
|
if (!newValues.includes(val)) newValues.push(val);
|
||||||
|
else newValues.splice(newValues.indexOf(val), 1);
|
||||||
});
|
});
|
||||||
else {
|
else {
|
||||||
if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||||
|
@ -1,33 +0,0 @@
|
|||||||
import { FC } from "react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
// hooks
|
|
||||||
import { useIssueDetail } from "@/hooks/store";
|
|
||||||
// components
|
|
||||||
import { IssueActivityList } from "./activity-list";
|
|
||||||
|
|
||||||
type TIssueActivityRoot = {
|
|
||||||
issueId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IssueActivityRoot: FC<TIssueActivityRoot> = observer((props) => {
|
|
||||||
const { issueId } = props;
|
|
||||||
// hooks
|
|
||||||
const {
|
|
||||||
activity: { getActivitiesByIssueId },
|
|
||||||
} = useIssueDetail();
|
|
||||||
|
|
||||||
const activityIds = getActivitiesByIssueId(issueId);
|
|
||||||
|
|
||||||
if (!activityIds) return <></>;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{activityIds.map((activityId, index) => (
|
|
||||||
<IssueActivityList
|
|
||||||
key={activityId}
|
|
||||||
activityId={activityId}
|
|
||||||
ends={index === 0 ? "top" : index === activityIds.length - 1 ? "bottom" : undefined}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
@ -3,7 +3,6 @@ export * from "./root";
|
|||||||
export * from "./activity-comment-root";
|
export * from "./activity-comment-root";
|
||||||
|
|
||||||
// activity
|
// activity
|
||||||
export * from "./activity/root";
|
|
||||||
export * from "./activity/activity-list";
|
export * from "./activity/activity-list";
|
||||||
|
|
||||||
// issue comment
|
// issue comment
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { FC, useMemo, useState } from "react";
|
import { FC, useMemo, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { History, LucideIcon, MessageCircle, ListRestart } from "lucide-react";
|
import { History, LucideIcon, MessageCircle } from "lucide-react";
|
||||||
// types
|
// types
|
||||||
import { TIssueComment } from "@plane/types";
|
import { TIssueComment } from "@plane/types";
|
||||||
// ui
|
// ui
|
||||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { IssueActivityCommentRoot, IssueActivityRoot, IssueCommentRoot, IssueCommentCreate } from "@/components/issues";
|
import { IssueActivityCommentRoot, IssueCommentRoot, IssueCommentCreate } from "@/components/issues";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail, useProject } from "@/hooks/store";
|
import { useIssueDetail, useProject } from "@/hooks/store";
|
||||||
|
|
||||||
@ -17,24 +17,19 @@ type TIssueActivity = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TActivityTabs = "all" | "activity" | "comments";
|
type TActivityTabs = "all" | "comments";
|
||||||
|
|
||||||
const activityTabs: { key: TActivityTabs; title: string; icon: LucideIcon }[] = [
|
const activityTabs: { key: TActivityTabs; title: string; icon: LucideIcon }[] = [
|
||||||
{
|
|
||||||
key: "all",
|
|
||||||
title: "All activity",
|
|
||||||
icon: History,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "activity",
|
|
||||||
title: "Updates",
|
|
||||||
icon: ListRestart,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "comments",
|
key: "comments",
|
||||||
title: "Comments",
|
title: "Comments",
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "all",
|
||||||
|
title: "All activity",
|
||||||
|
icon: History,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type TActivityOperations = {
|
export type TActivityOperations = {
|
||||||
@ -49,7 +44,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
|
|||||||
const { createComment, updateComment, removeComment } = useIssueDetail();
|
const { createComment, updateComment, removeComment } = useIssueDetail();
|
||||||
const { getProjectById } = useProject();
|
const { getProjectById } = useProject();
|
||||||
// state
|
// state
|
||||||
const [activityTab, setActivityTab] = useState<TActivityTabs>("all");
|
const [activityTab, setActivityTab] = useState<TActivityTabs>("comments");
|
||||||
|
|
||||||
const activityOperations: TActivityOperations = useMemo(
|
const activityOperations: TActivityOperations = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -158,8 +153,6 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : activityTab === "activity" ? (
|
|
||||||
<IssueActivityRoot issueId={issueId} />
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<IssueCommentRoot
|
<IssueCommentRoot
|
||||||
|
@ -94,7 +94,7 @@ export const CalendarOptionsDropdown: React.FC<ICalendarHeader> = observer((prop
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative">
|
<Popover className="relative flex items-center">
|
||||||
{({ open, close: closePopover }) => (
|
{({ open, close: closePopover }) => (
|
||||||
<>
|
<>
|
||||||
<Popover.Button as={React.Fragment}>
|
<Popover.Button as={React.Fragment}>
|
||||||
|
@ -28,9 +28,9 @@ export const AppliedCycleFilters: React.FC<Props> = observer((props) => {
|
|||||||
const cycleStatus = (cycleDetails?.status ? cycleDetails?.status.toLocaleLowerCase() : "draft") as TCycleGroups;
|
const cycleStatus = (cycleDetails?.status ? cycleDetails?.status.toLocaleLowerCase() : "draft") as TCycleGroups;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cycleId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
<div key={cycleId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs truncate">
|
||||||
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3 w-3 flex-shrink-0" />
|
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3 w-3 flex-shrink-0" />
|
||||||
<span className="normal-case">{cycleDetails.name}</span>
|
<span className="normal-case truncate">{cycleDetails.name}</span>
|
||||||
{editable && (
|
{editable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -60,9 +60,9 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={filterKey}
|
key={filterKey}
|
||||||
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
|
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize w-full"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5 w-full">
|
||||||
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
|
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
|
||||||
{membersFilters.includes(filterKey) && (
|
{membersFilters.includes(filterKey) && (
|
||||||
<AppliedMembersFilters
|
<AppliedMembersFilters
|
||||||
|
@ -24,9 +24,9 @@ export const AppliedModuleFilters: React.FC<Props> = observer((props) => {
|
|||||||
if (!moduleDetails) return null;
|
if (!moduleDetails) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={moduleId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
<div key={moduleId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs truncate">
|
||||||
<DiceIcon className="h-3 w-3 flex-shrink-0" />
|
<DiceIcon className="h-3 w-3 flex-shrink-0" />
|
||||||
<span className="normal-case">{moduleDetails.name}</span>
|
<span className="normal-case truncate">{moduleDetails.name}</span>
|
||||||
{editable && (
|
{editable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -233,7 +233,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
|||||||
className={`horizontal-scrollbar scrollbar-lg relative flex h-full w-full bg-custom-background-90 ${sub_group_by ? "vertical-scrollbar overflow-y-auto" : "overflow-x-auto overflow-y-hidden"}`}
|
className={`horizontal-scrollbar scrollbar-lg relative flex h-full w-full bg-custom-background-90 ${sub_group_by ? "vertical-scrollbar overflow-y-auto" : "overflow-x-auto overflow-y-hidden"}`}
|
||||||
ref={scrollableContainerRef}
|
ref={scrollableContainerRef}
|
||||||
>
|
>
|
||||||
<div className="relative h-full w-max min-w-full bg-custom-background-90 px-2">
|
<div className="relative h-full w-max min-w-full bg-custom-background-90">
|
||||||
{/* drag and delete component */}
|
{/* drag and delete component */}
|
||||||
<div
|
<div
|
||||||
className={`fixed left-1/2 -translate-x-1/2 ${
|
className={`fixed left-1/2 -translate-x-1/2 ${
|
||||||
|
@ -124,6 +124,10 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
|||||||
const [isDraggingOverBlock, setIsDraggingOverBlock] = useState(false);
|
const [isDraggingOverBlock, setIsDraggingOverBlock] = useState(false);
|
||||||
const [isCurrentBlockDragging, setIsCurrentBlockDragging] = useState(false);
|
const [isCurrentBlockDragging, setIsCurrentBlockDragging] = useState(false);
|
||||||
|
|
||||||
|
const canEditIssueProperties = canEditProperties(issue?.project_id);
|
||||||
|
|
||||||
|
const isDragAllowed = !isDragDisabled && !issue?.tempId && canEditIssueProperties;
|
||||||
|
|
||||||
// Make Issue block both as as Draggable and,
|
// Make Issue block both as as Draggable and,
|
||||||
// as a DropTarget for other issues being dragged to get the location of drop
|
// as a DropTarget for other issues being dragged to get the location of drop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -134,7 +138,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
|||||||
return combine(
|
return combine(
|
||||||
draggable({
|
draggable({
|
||||||
element,
|
element,
|
||||||
canDrag: () => !isDragDisabled,
|
canDrag: () => isDragAllowed,
|
||||||
getInitialData: () => ({ id: issue?.id, type: "ISSUE" }),
|
getInitialData: () => ({ id: issue?.id, type: "ISSUE" }),
|
||||||
onDragStart: () => {
|
onDragStart: () => {
|
||||||
setIsCurrentBlockDragging(true);
|
setIsCurrentBlockDragging(true);
|
||||||
@ -164,15 +168,13 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
|||||||
|
|
||||||
if (!issue) return null;
|
if (!issue) return null;
|
||||||
|
|
||||||
const canEditIssueProperties = canEditProperties(issue.project_id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropIndicator isVisible={!isCurrentBlockDragging && isDraggingOverBlock} />
|
<DropIndicator isVisible={!isCurrentBlockDragging && isDraggingOverBlock} />
|
||||||
<div
|
<div
|
||||||
// make Z-index higher at the beginning of drag, to have a issue drag image of issue block without any overlaps
|
// make Z-index higher at the beginning of drag, to have a issue drag image of issue block without any overlaps
|
||||||
className={cn("group/kanban-block relative p-1.5", { "z-[1]": isCurrentBlockDragging })}
|
className={cn("group/kanban-block relative p-1.5", { "z-[1]": isCurrentBlockDragging })}
|
||||||
onDragStart={() => !isDragDisabled && setIsCurrentBlockDragging(true)}
|
onDragStart={() => isDragAllowed && setIsCurrentBlockDragging(true)}
|
||||||
>
|
>
|
||||||
<ControlLink
|
<ControlLink
|
||||||
id={`issue-${issue.id}`}
|
id={`issue-${issue.id}`}
|
||||||
@ -186,7 +188,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"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-pointer": !isDragDisabled },
|
{ "hover:cursor-pointer": isDragAllowed },
|
||||||
{ "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id },
|
{ "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id },
|
||||||
{ "bg-custom-background-80 z-[100]": isCurrentBlockDragging }
|
{ "bg-custom-background-80 z-[100]": isCurrentBlockDragging }
|
||||||
)}
|
)}
|
||||||
|
@ -138,7 +138,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
|||||||
const isGroupByCreatedBy = group_by === "created_by";
|
const isGroupByCreatedBy = group_by === "created_by";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative w-full flex gap-2 ${sub_group_by ? "h-full" : "h-full"}`}>
|
<div className={`relative w-full flex gap-2 px-2 ${sub_group_by ? "h-full" : "h-full"}`}>
|
||||||
{list &&
|
{list &&
|
||||||
list.length > 0 &&
|
list.length > 0 &&
|
||||||
list.map((subList: IGroupByColumn) => {
|
list.map((subList: IGroupByColumn) => {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { FC } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Circle, ChevronDown, ChevronUp } from "lucide-react";
|
import { Circle, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { TIssueKanbanFilters } from "@plane/types";
|
import { TIssueKanbanFilters } from "@plane/types";
|
||||||
@ -13,13 +13,14 @@ interface IHeaderSubGroupByCard {
|
|||||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeaderSubGroupByCard = observer(
|
export const HeaderSubGroupByCard: FC<IHeaderSubGroupByCard> = observer((props) => {
|
||||||
({ icon, title, count, column_id, kanbanFilters, handleKanbanFilters }: IHeaderSubGroupByCard) => (
|
const { icon, title, count, column_id, kanbanFilters, handleKanbanFilters } = props;
|
||||||
<div className={`relative flex w-full flex-shrink-0 flex-row items-center gap-2 rounded-sm p-1.5`}>
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-[20px] w-[20px] flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80"
|
className={`relative flex w-full flex-shrink-0 flex-row items-center gap-2 rounded-sm p-1.5 cursor-pointer`}
|
||||||
onClick={() => handleKanbanFilters("sub_group_by", column_id)}
|
onClick={() => handleKanbanFilters("sub_group_by", column_id)}
|
||||||
>
|
>
|
||||||
|
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80">
|
||||||
{kanbanFilters?.sub_group_by.includes(column_id) ? (
|
{kanbanFilters?.sub_group_by.includes(column_id) ? (
|
||||||
<ChevronDown width={14} strokeWidth={2} />
|
<ChevronDown width={14} strokeWidth={2} />
|
||||||
) : (
|
) : (
|
||||||
@ -36,5 +37,5 @@ export const HeaderSubGroupByCard = observer(
|
|||||||
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
|
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
);
|
});
|
||||||
|
@ -175,23 +175,22 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
|||||||
<div className="relative h-max min-h-full w-full">
|
<div className="relative h-max min-h-full w-full">
|
||||||
{list &&
|
{list &&
|
||||||
list.length > 0 &&
|
list.length > 0 &&
|
||||||
list.map((_list: any) => {
|
list.map((_list: IGroupByColumn) => {
|
||||||
const subGroupByVisibilityToggle = visibilitySubGroupBy(_list);
|
const subGroupByVisibilityToggle = visibilitySubGroupBy(_list);
|
||||||
if (subGroupByVisibilityToggle.showGroup === false) return <></>;
|
if (subGroupByVisibilityToggle.showGroup === false) return <></>;
|
||||||
return (
|
return (
|
||||||
<div key={_list.id} className="flex flex-shrink-0 flex-col">
|
<div key={_list.id} className="flex flex-shrink-0 flex-col">
|
||||||
<div className="sticky top-[50px] z-[1] flex w-full items-center bg-custom-background-90 py-1">
|
<div className="sticky top-[50px] z-[1] py-1 flex w-full items-center bg-custom-background-100 border-y-[0.5px] border-custom-border-200">
|
||||||
<div className="sticky left-0 flex-shrink-0 bg-custom-background-90 pr-2">
|
<div className="sticky left-0 flex-shrink-0">
|
||||||
<HeaderSubGroupByCard
|
<HeaderSubGroupByCard
|
||||||
column_id={_list.id}
|
column_id={_list.id}
|
||||||
icon={_list.Icon}
|
icon={_list.icon}
|
||||||
title={_list.name || ""}
|
title={_list.name || ""}
|
||||||
count={calculateIssueCount(_list.id)}
|
count={calculateIssueCount(_list.id)}
|
||||||
kanbanFilters={kanbanFilters}
|
kanbanFilters={kanbanFilters}
|
||||||
handleKanbanFilters={handleKanbanFilters}
|
handleKanbanFilters={handleKanbanFilters}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full border-b border-dashed border-custom-border-400" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{subGroupByVisibilityToggle.showIssues && (
|
{subGroupByVisibilityToggle.showIssues && (
|
||||||
@ -313,7 +312,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="sticky top-0 z-[2] h-[50px] bg-custom-background-90">
|
<div className="sticky top-0 z-[2] h-[50px] bg-custom-background-90 px-2">
|
||||||
<SubGroupSwimlaneHeader
|
<SubGroupSwimlaneHeader
|
||||||
issueIds={issueIds}
|
issueIds={issueIds}
|
||||||
group_by={group_by}
|
group_by={group_by}
|
||||||
|
@ -54,8 +54,8 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full">
|
<div className="flex w-full truncate">
|
||||||
<div className="flex flex-grow items-center gap-3">
|
<div className="flex flex-grow items-center gap-3 truncate">
|
||||||
{displayProperties && displayProperties?.key && (
|
{displayProperties && displayProperties?.key && (
|
||||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
||||||
{projectIdentifier}-{issue.sequence_id}
|
{projectIdentifier}-{issue.sequence_id}
|
||||||
@ -68,7 +68,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
|||||||
|
|
||||||
{issue?.is_draft ? (
|
{issue?.is_draft ? (
|
||||||
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
|
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
|
||||||
<span>{issue.name}</span>
|
<p className="truncate">{issue.name}</p>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<ControlLink
|
<ControlLink
|
||||||
@ -78,11 +78,11 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
|||||||
}`}
|
}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
onClick={() => handleIssuePeekOverview(issue)}
|
onClick={() => handleIssuePeekOverview(issue)}
|
||||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
className="w-full truncate cursor-pointer text-sm text-custom-text-100"
|
||||||
disabled={!!issue?.tempId}
|
disabled={!!issue?.tempId}
|
||||||
>
|
>
|
||||||
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
|
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
|
||||||
<span>{issue.name}</span>
|
<p className="truncate">{issue.name}</p>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ControlLink>
|
</ControlLink>
|
||||||
)}
|
)}
|
||||||
@ -91,7 +91,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
|||||||
<div className="block md:hidden border border-custom-border-300 rounded ">{quickActions(issue)}</div>
|
<div className="block md:hidden border border-custom-border-300 rounded ">{quickActions(issue)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-0 md:ml-auto flex flex-wrap md:flex-shrink-0 items-center gap-2">
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
{!issue?.tempId ? (
|
{!issue?.tempId ? (
|
||||||
<>
|
<>
|
||||||
<IssueProperties
|
<IssueProperties
|
||||||
|
@ -28,7 +28,7 @@ export const IssueBlocksList: FC<Props> = (props) => {
|
|||||||
key={`${issueId}`}
|
key={`${issueId}`}
|
||||||
defaultHeight="3rem"
|
defaultHeight="3rem"
|
||||||
root={containerRef}
|
root={containerRef}
|
||||||
classNames={"relative border border-transparent border-b-custom-border-200 last:border-b-transparent"}
|
classNames={"relative border-b last:border-b-transparent"}
|
||||||
changingReference={issueIds}
|
changingReference={issueIds}
|
||||||
>
|
>
|
||||||
<IssueBlock
|
<IssueBlock
|
||||||
|
@ -190,6 +190,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
|
|
||||||
reset({
|
reset({
|
||||||
...defaultValues,
|
...defaultValues,
|
||||||
|
...(isCreateMoreToggleEnabled ? { ...data } : {}),
|
||||||
project_id: getValues("project_id"),
|
project_id: getValues("project_id"),
|
||||||
description_html: data?.description_html ?? "<p></p>",
|
description_html: data?.description_html ?? "<p></p>",
|
||||||
});
|
});
|
||||||
|
@ -112,22 +112,28 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button onClick={removeRoutePeekId}>
|
<Tooltip tooltipContent="Close the peek view" isMobile={isMobile}>
|
||||||
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
<button onClick={removeRoutePeekId}>
|
||||||
</button>
|
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Link href={`/${issueLink}`} onClick={() => removeRoutePeekId()}>
|
<Tooltip tooltipContent="Open issue in full screen" isMobile={isMobile}>
|
||||||
<MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
<Link href={`/${issueLink}`} onClick={() => removeRoutePeekId()}>
|
||||||
</Link>
|
<MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||||
|
</Link>
|
||||||
|
</Tooltip>
|
||||||
{currentMode && (
|
{currentMode && (
|
||||||
<div className="flex flex-shrink-0 items-center gap-2">
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={currentMode}
|
value={currentMode}
|
||||||
onChange={(val: any) => setPeekMode(val)}
|
onChange={(val: any) => setPeekMode(val)}
|
||||||
customButton={
|
customButton={
|
||||||
<button type="button" className="">
|
<Tooltip tooltipContent="Toggle peek view layout" isMobile={isMobile}>
|
||||||
<currentMode.icon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
<button type="button" className="">
|
||||||
</button>
|
<currentMode.icon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{PEEK_OPTIONS.map((mode) => (
|
{PEEK_OPTIONS.map((mode) => (
|
||||||
|
@ -43,12 +43,12 @@ export const ArchivedModulesHeader: FC = observer(() => {
|
|||||||
const handleFilters = useCallback(
|
const handleFilters = useCallback(
|
||||||
(key: keyof TModuleFilters, value: string | string[]) => {
|
(key: keyof TModuleFilters, value: string | string[]) => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
const newValues = currentProjectArchivedFilters?.[key] ?? [];
|
const newValues = currentProjectArchivedFilters?.[key] ?? [];
|
||||||
|
|
||||||
if (Array.isArray(value))
|
if (Array.isArray(value))
|
||||||
value.forEach((val) => {
|
value.forEach((val) => {
|
||||||
if (!newValues.includes(val)) newValues.push(val);
|
if (!newValues.includes(val)) newValues.push(val);
|
||||||
|
else newValues.splice(newValues.indexOf(val), 1);
|
||||||
});
|
});
|
||||||
else {
|
else {
|
||||||
if (currentProjectArchivedFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
if (currentProjectArchivedFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||||
|
@ -6,6 +6,8 @@ import { DateFilterModal } from "@/components/core";
|
|||||||
import { FilterHeader, FilterOption } from "@/components/issues";
|
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||||
// constants
|
// constants
|
||||||
import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters";
|
import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters";
|
||||||
|
// helpers
|
||||||
|
import { isInDateFormat } from "@/helpers/date-time.helper";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appliedFilters: string[] | null;
|
appliedFilters: string[] | null;
|
||||||
@ -25,6 +27,17 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
|
|||||||
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isCustomDateSelected = () => {
|
||||||
|
const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || [];
|
||||||
|
return isValidDateSelected.length > 0 ? true : false;
|
||||||
|
};
|
||||||
|
const handleCustomDate = () => {
|
||||||
|
if (isCustomDateSelected()) {
|
||||||
|
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
|
||||||
|
handleUpdate(updateAppliedFilters);
|
||||||
|
} else setIsDateFilterModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDateFilterModalOpen && (
|
{isDateFilterModalOpen && (
|
||||||
@ -53,7 +66,7 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
|
|||||||
multiple
|
multiple
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple />
|
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||||
|
@ -6,6 +6,8 @@ import { DateFilterModal } from "@/components/core";
|
|||||||
import { FilterHeader, FilterOption } from "@/components/issues";
|
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||||
// constants
|
// constants
|
||||||
import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters";
|
import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters";
|
||||||
|
// helpers
|
||||||
|
import { isInDateFormat } from "@/helpers/date-time.helper";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appliedFilters: string[] | null;
|
appliedFilters: string[] | null;
|
||||||
@ -25,6 +27,17 @@ export const FilterTargetDate: React.FC<Props> = observer((props) => {
|
|||||||
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isCustomDateSelected = () => {
|
||||||
|
const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || [];
|
||||||
|
return isValidDateSelected.length > 0 ? true : false;
|
||||||
|
};
|
||||||
|
const handleCustomDate = () => {
|
||||||
|
if (isCustomDateSelected()) {
|
||||||
|
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
|
||||||
|
handleUpdate(updateAppliedFilters);
|
||||||
|
} else setIsDateFilterModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDateFilterModalOpen && (
|
{isDateFilterModalOpen && (
|
||||||
@ -53,7 +66,7 @@ export const FilterTargetDate: React.FC<Props> = observer((props) => {
|
|||||||
multiple
|
multiple
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple />
|
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||||
|
@ -5,6 +5,8 @@ import { DateFilterModal } from "@/components/core";
|
|||||||
import { FilterHeader, FilterOption } from "@/components/issues";
|
import { FilterHeader, FilterOption } from "@/components/issues";
|
||||||
// constants
|
// constants
|
||||||
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
|
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
|
||||||
|
// helpers
|
||||||
|
import { isInDateFormat } from "@/helpers/date-time.helper";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appliedFilters: string[] | null;
|
appliedFilters: string[] | null;
|
||||||
@ -24,6 +26,17 @@ export const FilterCreatedDate: React.FC<Props> = observer((props) => {
|
|||||||
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isCustomDateSelected = () => {
|
||||||
|
const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || [];
|
||||||
|
return isValidDateSelected.length > 0 ? true : false;
|
||||||
|
};
|
||||||
|
const handleCustomDate = () => {
|
||||||
|
if (isCustomDateSelected()) {
|
||||||
|
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
|
||||||
|
handleUpdate(updateAppliedFilters);
|
||||||
|
} else setIsDateFilterModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDateFilterModalOpen && (
|
{isDateFilterModalOpen && (
|
||||||
@ -52,7 +65,7 @@ export const FilterCreatedDate: React.FC<Props> = observer((props) => {
|
|||||||
multiple
|
multiple
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple />
|
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
<p className="text-xs italic text-custom-text-400">No matches found</p>
|
||||||
|
@ -27,6 +27,7 @@ export const PageFiltersSelection: React.FC<Props> = observer((props) => {
|
|||||||
if (Array.isArray(value))
|
if (Array.isArray(value))
|
||||||
value.forEach((val) => {
|
value.forEach((val) => {
|
||||||
if (!newValues.includes(val)) newValues.push(val);
|
if (!newValues.includes(val)) newValues.push(val);
|
||||||
|
else newValues.splice(newValues.indexOf(val), 1);
|
||||||
});
|
});
|
||||||
else if (typeof value === "string") {
|
else if (typeof value === "string") {
|
||||||
if (newValues?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
if (newValues?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
import { IUserLite } from "@plane/types";
|
|
||||||
// hooks
|
|
||||||
import { Button } from "@plane/ui";
|
|
||||||
import { useUser } from "@/hooks/store";
|
|
||||||
// ui
|
|
||||||
// types
|
// types
|
||||||
|
import { IUserLite } from "@plane/types";
|
||||||
|
// ui
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
|
// hooks
|
||||||
|
import { useProject, useUser } from "@/hooks/store";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: IUserLite;
|
data: IUserLite;
|
||||||
@ -18,10 +19,14 @@ type Props = {
|
|||||||
|
|
||||||
export const ConfirmProjectMemberRemove: React.FC<Props> = observer((props) => {
|
export const ConfirmProjectMemberRemove: React.FC<Props> = observer((props) => {
|
||||||
const { data, onSubmit, isOpen, onClose } = props;
|
const { data, onSubmit, isOpen, onClose } = props;
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
|
const { projectId } = router.query;
|
||||||
// states
|
// states
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
// store hooks
|
// store hooks
|
||||||
const { currentUser } = useUser();
|
const { currentUser } = useUser();
|
||||||
|
const { getProjectById } = useProject();
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onClose();
|
onClose();
|
||||||
@ -36,7 +41,10 @@ export const ConfirmProjectMemberRemove: React.FC<Props> = observer((props) => {
|
|||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!projectId) return <></>;
|
||||||
|
|
||||||
const isCurrentUser = currentUser?.id === data?.id;
|
const isCurrentUser = currentUser?.id === data?.id;
|
||||||
|
const currentProjectDetails = getProjectById(projectId.toString());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||||
@ -76,9 +84,19 @@ export const ConfirmProjectMemberRemove: React.FC<Props> = observer((props) => {
|
|||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-sm text-custom-text-200">
|
<p className="text-sm text-custom-text-200">
|
||||||
Are you sure you want to remove member-{" "}
|
{isCurrentUser ? (
|
||||||
<span className="font-bold">{data?.display_name}</span>? They will no longer have access to
|
<>
|
||||||
this project. This action cannot be undone.
|
Are you sure you want to leave the{" "}
|
||||||
|
<span className="font-bold">{currentProjectDetails?.name}</span> project? You will be able
|
||||||
|
to join the project if invited again or if it{"'"}s public.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Are you sure you want to remove member-{" "}
|
||||||
|
<span className="font-bold">{data?.display_name}</span>? They will no longer have access
|
||||||
|
to this project. This action cannot be undone.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,7 +6,7 @@ import { FilterHeader, FilterOption } from "@/components/issues";
|
|||||||
// constants
|
// constants
|
||||||
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
|
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
|
||||||
// helpers
|
// helpers
|
||||||
import { isDate } from "@/helpers/date-time.helper";
|
import { isInDateFormat } from "@/helpers/date-time.helper";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appliedFilters: string[] | null;
|
appliedFilters: string[] | null;
|
||||||
@ -27,7 +27,7 @@ export const FilterCreatedDate: React.FC<Props> = observer((props) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isCustomDateSelected = () => {
|
const isCustomDateSelected = () => {
|
||||||
const isValidDateSelected = appliedFilters?.filter((f) => isDate(f.split(";")[0])) || [];
|
const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || [];
|
||||||
return isValidDateSelected.length > 0 ? true : false;
|
return isValidDateSelected.length > 0 ? true : false;
|
||||||
};
|
};
|
||||||
const handleCustomDate = () => {
|
const handleCustomDate = () => {
|
||||||
|
@ -35,8 +35,8 @@ export const ProjectFeatureUpdate: FC<Props> = observer((props) => {
|
|||||||
<ProjectFeaturesList workspaceSlug={workspaceSlug} projectId={projectId} isAdmin />
|
<ProjectFeaturesList workspaceSlug={workspaceSlug} projectId={projectId} isAdmin />
|
||||||
<div className="flex items-center justify-between gap-2 mt-4 px-4 pt-4 pb-2 border-t border-custom-border-100">
|
<div className="flex items-center justify-between gap-2 mt-4 px-4 pt-4 pb-2 border-t border-custom-border-100">
|
||||||
<div className="text-sm text-custom-text-300 font-medium">
|
<div className="text-sm text-custom-text-300 font-medium">
|
||||||
Congrats! Project <ProjectLogo logo={currentProjectDetails.logo_props} /> {currentProjectDetails.name}{" "}
|
Congrats! Project <ProjectLogo logo={currentProjectDetails.logo_props} />{" "}
|
||||||
created.
|
<p className="break-all">{currentProjectDetails.name}</p> created.
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={1}>
|
<Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={1}>
|
||||||
|
@ -225,7 +225,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
|
|||||||
"justify-center": isCollapsed,
|
"justify-center": isCollapsed,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="h-7 w-7 grid place-items-center">
|
<div className="h-7 w-7 grid place-items-center flex-shrink-0">
|
||||||
<ProjectLogo logo={project.logo_props} />
|
<ProjectLogo logo={project.logo_props} />
|
||||||
</div>
|
</div>
|
||||||
{!isCollapsed && <p className="truncate text-custom-sidebar-text-200">{project.name}</p>}
|
{!isCollapsed && <p className="truncate text-custom-sidebar-text-200">{project.name}</p>}
|
||||||
|
@ -224,7 +224,7 @@ export const getDate = (date: string | Date | undefined | null): Date | undefine
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export const isDate = (date: string) => {
|
export const isInDateFormat = (date: string) => {
|
||||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
return datePattern.test(date);
|
return datePattern.test(date);
|
||||||
};
|
};
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// hooks
|
|
||||||
import { useUser } from "@/hooks/store";
|
|
||||||
// types
|
// types
|
||||||
import { IUser, IUserSettings } from "@plane/types";
|
import { IUser, IUserSettings, IWorkspace } from "@plane/types";
|
||||||
|
// hooks
|
||||||
|
import { useUser, useWorkspace } from "@/hooks/store";
|
||||||
|
|
||||||
type UseSignInRedirectionProps = {
|
type UseSignInRedirectionProps = {
|
||||||
error: any | null;
|
error: any | null;
|
||||||
@ -20,6 +20,7 @@ const useSignInRedirection = (): UseSignInRedirectionProps => {
|
|||||||
const { next_path } = router.query;
|
const { next_path } = router.query;
|
||||||
// mobx store
|
// mobx store
|
||||||
const { fetchCurrentUser, fetchCurrentUserSettings } = useUser();
|
const { fetchCurrentUser, fetchCurrentUserSettings } = useUser();
|
||||||
|
const { fetchWorkspaces } = useWorkspace();
|
||||||
|
|
||||||
const isValidURL = (url: string): boolean => {
|
const isValidURL = (url: string): boolean => {
|
||||||
const disallowedSchemes = /^(https?|ftp):\/\//i;
|
const disallowedSchemes = /^(https?|ftp):\/\//i;
|
||||||
@ -47,20 +48,26 @@ const useSignInRedirection = (): UseSignInRedirectionProps => {
|
|||||||
|
|
||||||
// Fetch the current user settings
|
// Fetch the current user settings
|
||||||
const userSettings: IUserSettings = await fetchCurrentUserSettings();
|
const userSettings: IUserSettings = await fetchCurrentUserSettings();
|
||||||
|
const workspacesList: IWorkspace[] = await fetchWorkspaces();
|
||||||
|
|
||||||
// Extract workspace details
|
// Extract workspace details
|
||||||
const workspaceSlug =
|
const workspaceSlug =
|
||||||
userSettings?.workspace?.last_workspace_slug || userSettings?.workspace?.fallback_workspace_slug;
|
userSettings?.workspace?.last_workspace_slug || userSettings?.workspace?.fallback_workspace_slug;
|
||||||
|
|
||||||
// Redirect based on workspace details or to profile if not available
|
// Redirect based on workspace details or to profile if not available
|
||||||
if (workspaceSlug) router.push(`/${workspaceSlug}`);
|
if (
|
||||||
|
workspaceSlug &&
|
||||||
|
workspacesList &&
|
||||||
|
workspacesList.filter((workspace) => workspace.slug === workspaceSlug).length > 0
|
||||||
|
)
|
||||||
|
router.push(`/${workspaceSlug}`);
|
||||||
else router.push("/profile");
|
else router.push("/profile");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in handleSignInRedirection:", error);
|
console.error("Error in handleSignInRedirection:", error);
|
||||||
setError(error);
|
setError(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchCurrentUserSettings, router, next_path]
|
[fetchCurrentUserSettings, fetchWorkspaces, router, next_path]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateUserInfo = useCallback(async () => {
|
const updateUserInfo = useCallback(async () => {
|
||||||
|
@ -269,6 +269,10 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await this.createIssue(workspaceSlug, projectId, data, cycleId);
|
const response = await this.createIssue(workspaceSlug, projectId, data, cycleId);
|
||||||
|
|
||||||
|
if (data.module_ids && data.module_ids.length > 0)
|
||||||
|
await this.rootStore.moduleIssues.addModulesToIssue(workspaceSlug, projectId, response.id, data.module_ids);
|
||||||
|
|
||||||
this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId);
|
this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId);
|
||||||
|
|
||||||
const quickAddIssueIndex = this.issues[cycleId].findIndex((_issueId) => _issueId === data.id);
|
const quickAddIssueIndex = this.issues[cycleId].findIndex((_issueId) => _issueId === data.id);
|
||||||
|
@ -274,6 +274,10 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await this.createIssue(workspaceSlug, projectId, data, moduleId);
|
const response = await this.createIssue(workspaceSlug, projectId, data, moduleId);
|
||||||
|
|
||||||
|
if (data.cycle_id && data.cycle_id !== "")
|
||||||
|
await this.rootStore.cycleIssues.addIssueToCycle(workspaceSlug, projectId, data.cycle_id, [response.id]);
|
||||||
|
|
||||||
this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId);
|
this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId);
|
||||||
|
|
||||||
const quickAddIssueIndex = this.issues[moduleId].findIndex((_issueId) => _issueId === data.id);
|
const quickAddIssueIndex = this.issues[moduleId].findIndex((_issueId) => _issueId === data.id);
|
||||||
|
@ -239,6 +239,12 @@ export class ProjectViewIssues extends IssueHelperStore implements IProjectViewI
|
|||||||
|
|
||||||
const response = await this.createIssue(workspaceSlug, projectId, data, viewId);
|
const response = await this.createIssue(workspaceSlug, projectId, data, viewId);
|
||||||
|
|
||||||
|
if (data.cycle_id && data.cycle_id !== "")
|
||||||
|
await this.rootStore.cycleIssues.addIssueToCycle(workspaceSlug, projectId, data.cycle_id, [response.id]);
|
||||||
|
|
||||||
|
if (data.module_ids && data.module_ids.length > 0)
|
||||||
|
await this.rootStore.moduleIssues.addModulesToIssue(workspaceSlug, projectId, response.id, data.module_ids);
|
||||||
|
|
||||||
const quickAddIssueIndex = this.issues[viewId].findIndex((_issueId) => _issueId === data.id);
|
const quickAddIssueIndex = this.issues[viewId].findIndex((_issueId) => _issueId === data.id);
|
||||||
if (quickAddIssueIndex >= 0)
|
if (quickAddIssueIndex >= 0)
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
|
@ -223,6 +223,12 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues {
|
|||||||
|
|
||||||
const response = await this.createIssue(workspaceSlug, projectId, data);
|
const response = await this.createIssue(workspaceSlug, projectId, data);
|
||||||
|
|
||||||
|
if (data.cycle_id && data.cycle_id !== "")
|
||||||
|
await this.rootStore.cycleIssues.addIssueToCycle(workspaceSlug, projectId, data.cycle_id, [response.id]);
|
||||||
|
|
||||||
|
if (data.module_ids && data.module_ids.length > 0)
|
||||||
|
await this.rootStore.moduleIssues.addModulesToIssue(workspaceSlug, projectId, response.id, data.module_ids);
|
||||||
|
|
||||||
const quickAddIssueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === data.id);
|
const quickAddIssueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === data.id);
|
||||||
if (quickAddIssueIndex >= 0)
|
if (quickAddIssueIndex >= 0)
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
|
@ -96,7 +96,7 @@ export class LabelStore implements ILabelStore {
|
|||||||
const workspaceSlug = this.rootStore.app.router.workspaceSlug || "";
|
const workspaceSlug = this.rootStore.app.router.workspaceSlug || "";
|
||||||
if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
|
if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
|
||||||
return sortBy(
|
return sortBy(
|
||||||
Object.values(this.labelMap).filter((label) => label.project_id === projectId),
|
Object.values(this.labelMap).filter((label) => label?.project_id === projectId),
|
||||||
"sort_order"
|
"sort_order"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -113,7 +113,7 @@ export class LabelStore implements ILabelStore {
|
|||||||
const workspaceSlug = this.rootStore.app.router.workspaceSlug || "";
|
const workspaceSlug = this.rootStore.app.router.workspaceSlug || "";
|
||||||
if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
|
if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
|
||||||
return sortBy(
|
return sortBy(
|
||||||
Object.values(this.labelMap).filter((label) => label.project_id === projectId),
|
Object.values(this.labelMap).filter((label) => label?.project_id === projectId),
|
||||||
"sort_order"
|
"sort_order"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
import sortBy from "lodash/sortBy";
|
import sortBy from "lodash/sortBy";
|
||||||
|
import update from "lodash/update";
|
||||||
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
import { action, computed, observable, makeObservable, runInAction } from "mobx";
|
||||||
import { computedFn } from "mobx-utils";
|
import { computedFn } from "mobx-utils";
|
||||||
// types
|
// types
|
||||||
@ -286,7 +287,7 @@ export class ModulesStore implements IModuleStore {
|
|||||||
fetchArchivedModuleDetails = async (workspaceSlug: string, projectId: string, moduleId: string) =>
|
fetchArchivedModuleDetails = async (workspaceSlug: string, projectId: string, moduleId: string) =>
|
||||||
await this.moduleArchiveService.getArchivedModuleDetails(workspaceSlug, projectId, moduleId).then((response) => {
|
await this.moduleArchiveService.getArchivedModuleDetails(workspaceSlug, projectId, moduleId).then((response) => {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
set(this.moduleMap, [moduleId], response);
|
set(this.moduleMap, [response.id], { ...this.moduleMap?.[response.id], ...response });
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user