Compare commits

...
Author SHA1 Message Date
Palanikannan1437 69034187be fix: error handling editor 2024-04-24 14:18:37 +05:30
Anmol Singh BhatiaandGitHub a4e5138d1c [WEB-1047] chore: create page modal improvement (#4266)
* chore: create page modal improvement

* chore: create page modal improvement
2024-04-23 19:59:34 +05:30
NikhilandGitHub f174a96ef2 dev: update python runtime (#4259) 2024-04-23 13:42:48 +05:30
sriram veeraghanta 5e53279734 Merge branch 'develop' of github.com:makeplane/plane into preview 2024-04-23 13:27:49 +05:30
Anmol Singh BhatiaandGitHub bf852739cd fix: preserve initial value on create more issues (#4258) 2024-04-23 13:25:27 +05:30
Anmol Singh BhatiaandGitHub f17e4c73a2 [WEB-1015] fix: kanban layout cycle and module quick add (#4252)
* fix: kanban layout cycle and module quick add

* fix: kanban layout cycle and module quick add
2024-04-23 13:07:20 +05:30
Bavisetti NarayanandGitHub aee48f6fa4 [WEB-1042] fix: dashboard collaborators active issue count (#4256)
* chore: recent collaborators based on workspace

* chore: removed the duplicate issue
2024-04-23 13:04:14 +05:30
Anmol Singh BhatiaandGitHub f7d6219bd1 [WEB-1038] fix: kanban layout drag permission validation (#4255)
* fix: kanban layout drag permission validation

* chore: code refactor
2024-04-23 13:03:30 +05:30
NikhilandGitHub 75d14ce1ef fix: workspace redirection when logging in (#4254) 2024-04-23 13:02:48 +05:30
Anmol Singh BhatiaandGitHub 9659da5b31 [WEB-1034] fix: inbox issue user activity (#4251)
* fix: inbox issue user activity

* chore: code refactor

* fix: sentry issue
2024-04-23 13:01:26 +05:30
Anmol Singh BhatiaandGitHub 51ade1295c fix: comment text editor user mention validation addded (#4250) 2024-04-23 13:00:58 +05:30
Anmol Singh BhatiaandGitHub ac3def2929 fix: calendar header action alignment (#4249) 2024-04-23 12:59:48 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
098a1950c7 chore(deps): bump gunicorn in /apiserver/requirements (#4247)
Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 21.2.0 to 22.0.0.
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/21.2.0...22.0.0)

---
updated-dependencies:
- dependency-name: gunicorn
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 12:58:28 +05:30
03fd5feda6 [WEB-1035] fix: peek module auto closing (#4246)
* fix: peek module auto closing

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: archived at in module

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-04-23 12:58:01 +05:30
Anmol Singh BhatiaandGitHub 270f7c4503 fix: sign in redirection flicker (#4245) 2024-04-23 12:57:10 +05:30
Anmol Singh BhatiaandGitHub beff8536c9 fix: filter custom date select toggle (#4244) 2024-04-23 12:56:21 +05:30
Anmol Singh BhatiaandGitHub 5d8c5b22e8 chore: kanban layout sub-group ui revamp & sub-group icon fix (#4243) 2024-04-23 12:55:42 +05:30
Anmol Singh BhatiaandGitHub 60663821df chore: list layout issue block improvement (#4241) 2024-04-23 12:55:19 +05:30
Prateek ShouryaandGitHub 302da646a8 [WEB-857] chore: update issue priority text color for custom theme in spreadsheet layout. (#4235) 2024-04-23 12:54:19 +05:30
Anmol Singh BhatiaandGitHub e0e8ce633b [WEB-1027] fix: overflow & alignment fixes (#4234)
* fix: list layout issue title overflow

* fix: project feature toggle modal project name overflow

* fix: app sidebar project section alignment

* fix: issue title textarea

* fix: create issue modal project select overflow

* fix: module and cycle applied filters overflow fix
2024-04-23 12:53:52 +05:30
Prateek ShouryaandGitHub f77d2d8c0a [WEB-643] chore: update issue activity tabs. (#4232)
* remove `updates` tab.
* make `comments` as primary tab.
2024-04-23 12:52:31 +05:30
Prateek ShouryaandGitHub c50a0602f7 [WEB-871] chore: update leave project modal message in members settings page. (#4230)
* [WEB-871] chore: update leave project modal message in members settings page.

* fix: build errors.
2024-04-23 12:51:20 +05:30
Prateek ShouryaandGitHub 38daf72361 [WEB-872] chore: add tooltip to peek overview header icons. (#4229) 2024-04-23 12:49:29 +05:30
63 changed files with 1154 additions and 893 deletions
@@ -210,6 +210,7 @@ class ModuleSerializer(DynamicBaseSerializer):
"backlog_issues",
"created_at",
"updated_at",
"archived_at",
]
read_only_fields = fields
+8 -3
View File
@@ -38,7 +38,7 @@ from .workspace.base import (
WorkSpaceAvailabilityCheckEndpoint,
UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet,
ExportWorkspaceUserActivityEndpoint
ExportWorkspaceUserActivityEndpoint,
)
from .workspace.member import (
@@ -91,12 +91,14 @@ from .cycle.base import (
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleArchiveUnarchiveEndpoint,
CycleUserPropertiesEndpoint,
)
from .cycle.issue import (
CycleIssueViewSet,
)
from .cycle.archive import (
CycleArchiveUnarchiveEndpoint,
)
from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .issue.base import (
@@ -170,7 +172,6 @@ from .module.base import (
ModuleViewSet,
ModuleLinkViewSet,
ModuleFavoriteViewSet,
ModuleArchiveUnarchiveEndpoint,
ModuleUserPropertiesEndpoint,
)
@@ -178,6 +179,10 @@ from .module.issue import (
ModuleIssueViewSet,
)
from .module.archive import (
ModuleArchiveUnarchiveEndpoint,
)
from .api import ApiTokenEndpoint
+409
View 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 -377
View File
@@ -1,10 +1,9 @@
# Python imports
import json
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Django imports
from django.db.models import (
Case,
CharField,
@@ -25,7 +24,6 @@ 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,
ProjectLitePermission,
@@ -686,380 +684,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
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):
permission_classes = [
ProjectEntityPermission,
+10 -5
View File
@@ -571,14 +571,16 @@ def dashboard_recent_collaborators(self, request, slug):
return self.paginate(
request=request,
queryset=project_members_with_activities,
controller=self.get_results_controller,
controller=lambda qs: self.get_results_controller(qs, slug),
)
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.objects.filter(id__in=project_members_with_activities)
User.objects.filter(
id__in=project_members_with_activities,
)
.annotate(
active_issue_count=Count(
Case(
@@ -587,10 +589,13 @@ class DashboardEndpoint(BaseAPIView):
"unstarted",
"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(),
)
),
distinct=True,
)
)
.values("active_issue_count", user_id=F("id"))
+2 -3
View File
@@ -1,6 +1,7 @@
# Python imports
import json
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
@@ -19,14 +20,12 @@ from django.db.models import (
When,
)
from django.db.models.functions import Coalesce
# Django imports
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from rest_framework import status
# Third Party imports
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission
+356
View 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)
-319
View File
@@ -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):
serializer_class = ModuleFavoriteSerializer
model = ModuleFavorite
+6 -1
View File
@@ -49,7 +49,12 @@ class UserEndpoint(BaseViewSet):
{"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):
return super().partial_update(request, *args, **kwargs)
@@ -15,7 +15,7 @@ class Command(BaseCommand):
receiver_email = options.get("to_email")
if not receiver_email:
raise CommandError("Reciever email is required")
raise CommandError("Receiver email is required")
(
EMAIL_HOST,
@@ -54,7 +54,7 @@ class Command(BaseCommand):
connection=connection,
)
msg.send()
self.stdout.write(self.style.SUCCESS("Email succesfully sent"))
self.stdout.write(self.style.SUCCESS("Email successfully sent"))
except Exception as e:
self.stdout.write(
self.style.ERROR(
+1 -1
View File
@@ -1,3 +1,3 @@
-r base.txt
gunicorn==21.2.0
gunicorn==22.0.0
+1 -1
View File
@@ -1 +1 @@
python-3.11.8
python-3.11.9
@@ -56,6 +56,8 @@ export const useEditor = ({
mentionHandler,
placeholder,
}: CustomEditorProps) => {
const [error, setError] = useState<string | null>(null);
const editor = useCustomEditor({
editorProps: {
...CoreEditorProps(editorClassName),
@@ -86,6 +88,10 @@ export const useEditor = ({
setSavedSelection(editor.state.selection);
},
onUpdate: async ({ editor }) => {
const shouldError = Math.random() < 0.05; // 10% chance to generate an error
if (shouldError) {
setError("Randomly generated error for testing purposes.");
}
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
},
onDestroy: async () => {
@@ -203,6 +209,11 @@ export const useEditor = ({
[editorRef, savedSelection, uploadFile]
);
if (error) {
console.log("threw error from core");
throw new Error(error);
}
if (!editor) {
return null;
}
@@ -49,7 +49,6 @@ const RichTextEditor = (props: IRichTextEditor) => {
containerClassName,
editorClassName = "",
forwardedRef,
// rerenderOnPropsChange,
id = "",
placeholder,
tabIndex,
@@ -58,8 +57,6 @@ const RichTextEditor = (props: IRichTextEditor) => {
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
// loads such that we can invoke it from react when the cursor leaves the container
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
};
@@ -75,7 +72,6 @@ const RichTextEditor = (props: IRichTextEditor) => {
initialValue,
value,
forwardedRef,
// rerenderOnPropsChange,
extensions: RichTextEditorExtensions({
uploadFile: fileHandler.upload,
dragDropEnabled,
+1 -13
View File
@@ -11,17 +11,7 @@ export interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextArea
}
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, ref) => {
const {
id,
name,
value = "",
rows = 1,
cols = 1,
mode = "primary",
hasError = false,
className = "",
...rest
} = props;
const { id, name, value = "", mode = "primary", hasError = false, className = "", ...rest } = props;
// refs
const textAreaRef = useRef<any>(ref);
// auto re-size
@@ -33,8 +23,6 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>((props, re
name={name}
ref={textAreaRef}
value={value}
rows={rows}
cols={cols}
className={cn(
"no-scrollbar w-full bg-transparent px-3 py-2 placeholder-custom-text-400 outline-none",
{
+1 -2
View File
@@ -34,10 +34,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
return !!ref && typeof ref === "object" && "current" in ref;
}
const isEmpty =
props.initialValue === "" ||
props.initialValue?.trim() === "" ||
props.initialValue === "<p></p>" ||
isEmptyHtmlString(props.initialValue ?? "");
(isEmptyHtmlString(props.initialValue ?? "") && !props.initialValue?.includes("mention-component"));
return (
<div className="border border-custom-border-200 rounded p-3 space-y-3">
+50 -2
View File
@@ -17,6 +17,7 @@ import {
SignalMediumIcon,
MessageSquareIcon,
UsersIcon,
Inbox,
} from "lucide-react";
import { IIssueActivity } from "@plane/types";
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: {
[key: string]: {
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>
{showIssue && (
<>
{" "}
for <IssueLink activity={activity} />
<IssueLink activity={activity} />
</>
)}
</>
@@ -667,6 +701,20 @@ const activityDetails: {
},
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 }) => (
@@ -34,12 +34,12 @@ export const ArchivedCyclesHeader: FC = observer(() => {
const handleFilters = useCallback(
(key: keyof TCycleFilters, value: string | string[]) => {
if (!projectId) return;
const newValues = currentProjectArchivedFilters?.[key] ?? [];
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
else {
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(
(key: keyof TCycleFilters, value: string | string[]) => {
if (!projectId) return;
const newValues = currentProjectFilters?.[key] ?? [];
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
else {
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";
// constants
import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters";
// helpers
import { isInDateFormat } from "@/helpers/date-time.helper";
type Props = {
appliedFilters: string[] | null;
@@ -25,6 +27,17 @@ export const FilterEndDate: React.FC<Props> = observer((props) => {
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 (
<>
{isDateFilterModalOpen && (
@@ -53,7 +66,7 @@ export const FilterEndDate: React.FC<Props> = observer((props) => {
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>
@@ -6,6 +6,8 @@ import { DateFilterModal } from "@/components/core";
import { FilterHeader, FilterOption } from "@/components/issues";
// constants
import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters";
// helpers
import { isInDateFormat } from "@/helpers/date-time.helper";
type Props = {
appliedFilters: string[] | null;
@@ -25,6 +27,17 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
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 (
<>
{isDateFilterModalOpen && (
@@ -53,7 +66,7 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
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>
+1 -1
View File
@@ -417,7 +417,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
<ButtonToRender
priority={value}
className={cn(buttonClassName, {
"text-white": resolvedTheme === "dark",
"text-custom-text-200": resolvedTheme?.includes("dark") || resolvedTheme === "custom",
})}
highlightUrgent={highlightUrgent}
dropdownArrow={dropdownArrow && !disabled}
+1 -1
View File
@@ -176,7 +176,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
</span>
)}
{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 && (
<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 =
props.initialValue === "" ||
props.initialValue?.trim() === "" ||
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> {
return !!ref && typeof ref === "object" && "current" in ref;
@@ -1,4 +1,4 @@
import React, { forwardRef } from "react";
import { forwardRef } from "react";
// editor
import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/rich-text-editor";
// types
@@ -9,6 +9,7 @@ import { cn } from "@/helpers/common.helper";
import { useMember, useMention, useUser } from "@/hooks/store";
// services
import { FileService } from "@/services/file.service";
import ErrorBoundaryWithFallback from "./rich-text-fallback";
interface RichTextEditorWrapperProps extends Omit<IRichTextEditor, "fileHandler" | "mentionHandler"> {
workspaceSlug: string;
@@ -20,6 +21,7 @@ const fileService = new FileService();
export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {
const { containerClassName, workspaceSlug, workspaceId, projectId, ...rest } = props;
console.log("eeeeeeee", rest.value);
// store hooks
const { currentUser } = useUser();
const {
@@ -38,21 +40,23 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
});
return (
<RichTextEditorWithRef
ref={ref}
fileHandler={{
upload: fileService.getUploadFileFunction(workspaceSlug),
delete: fileService.getDeleteImageFunction(workspaceId),
restore: fileService.getRestoreImageFunction(workspaceId),
cancel: fileService.cancelUpload,
}}
mentionHandler={{
highlights: mentionHighlights,
suggestions: mentionSuggestions,
}}
{...rest}
containerClassName={cn(containerClassName, "relative min-h-[150px] border border-custom-border-200 p-3")}
/>
<ErrorBoundaryWithFallback>
<RichTextEditorWithRef
ref={ref}
fileHandler={{
upload: fileService.getUploadFileFunction(workspaceSlug),
delete: fileService.getDeleteImageFunction(workspaceId),
restore: fileService.getRestoreImageFunction(workspaceId),
cancel: fileService.cancelUpload,
}}
mentionHandler={{
highlights: mentionHighlights,
suggestions: mentionSuggestions,
}}
{...rest}
containerClassName={cn(containerClassName, "relative min-h-[150px] border border-custom-border-200 p-3")}
/>
</ErrorBoundaryWithFallback>
);
});
@@ -0,0 +1,41 @@
import React, { useEffect } from "react";
import { ErrorBoundary } from "react-error-boundary";
interface FallbackProps {
error: Error;
resetErrorBoundary: () => void;
}
const Fallback: React.FC<FallbackProps> = ({ error, resetErrorBoundary }) => {
useEffect(() => {
const timer = setTimeout(() => {
resetErrorBoundary();
}, 1000);
return () => clearTimeout(timer);
}, [resetErrorBoundary]);
return (
<div role="alert">
<p>Something went wrong:</p>
<pre style={{ color: "red" }}>{error.message}</pre>
</div>
);
};
interface ErrorBoundaryWithFallbackProps {
children: React.ReactNode;
}
const ErrorBoundaryWithFallback: React.FC<ErrorBoundaryWithFallbackProps> = ({ children }) => {
return (
<ErrorBoundary
FallbackComponent={Fallback}
onError={(error, errorInfo) => console.log("Error occurred:", error, errorInfo)}
>
{children}
</ErrorBoundary>
);
};
export default ErrorBoundaryWithFallback;
+1
View File
@@ -62,6 +62,7 @@ export const ModulesListHeader: React.FC = observer(() => {
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
else {
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";
// activity
export * from "./activity/root";
export * from "./activity/activity-list";
// issue comment
@@ -1,12 +1,12 @@
import { FC, useMemo, useState } from "react";
import { observer } from "mobx-react-lite";
import { History, LucideIcon, MessageCircle, ListRestart } from "lucide-react";
import { History, LucideIcon, MessageCircle } from "lucide-react";
// types
import { TIssueComment } from "@plane/types";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { IssueActivityCommentRoot, IssueActivityRoot, IssueCommentRoot, IssueCommentCreate } from "@/components/issues";
import { IssueActivityCommentRoot, IssueCommentRoot, IssueCommentCreate } from "@/components/issues";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
@@ -17,24 +17,19 @@ type TIssueActivity = {
disabled?: boolean;
};
type TActivityTabs = "all" | "activity" | "comments";
type TActivityTabs = "all" | "comments";
const activityTabs: { key: TActivityTabs; title: string; icon: LucideIcon }[] = [
{
key: "all",
title: "All activity",
icon: History,
},
{
key: "activity",
title: "Updates",
icon: ListRestart,
},
{
key: "comments",
title: "Comments",
icon: MessageCircle,
},
{
key: "all",
title: "All activity",
icon: History,
},
];
export type TActivityOperations = {
@@ -49,7 +44,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
const { createComment, updateComment, removeComment } = useIssueDetail();
const { getProjectById } = useProject();
// state
const [activityTab, setActivityTab] = useState<TActivityTabs>("all");
const [activityTab, setActivityTab] = useState<TActivityTabs>("comments");
const activityOperations: TActivityOperations = useMemo(
() => ({
@@ -158,8 +153,6 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
/>
)}
</div>
) : activityTab === "activity" ? (
<IssueActivityRoot issueId={issueId} />
) : (
<div className="space-y-3">
<IssueCommentRoot
@@ -94,7 +94,7 @@ export const CalendarOptionsDropdown: React.FC<ICalendarHeader> = observer((prop
};
return (
<Popover className="relative">
<Popover className="relative flex items-center">
{({ open, close: closePopover }) => (
<>
<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;
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" />
<span className="normal-case">{cycleDetails.name}</span>
<span className="normal-case truncate">{cycleDetails.name}</span>
{editable && (
<button
type="button"
@@ -60,9 +60,9 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
return (
<div
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>
{membersFilters.includes(filterKey) && (
<AppliedMembersFilters
@@ -24,9 +24,9 @@ export const AppliedModuleFilters: React.FC<Props> = observer((props) => {
if (!moduleDetails) return null;
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" />
<span className="normal-case">{moduleDetails.name}</span>
<span className="normal-case truncate">{moduleDetails.name}</span>
{editable && (
<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"}`}
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 */}
<div
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 [isCurrentBlockDragging, setIsCurrentBlockDragging] = useState(false);
const canEditIssueProperties = canEditProperties(issue?.project_id);
const isDragAllowed = !isDragDisabled && !issue?.tempId && canEditIssueProperties;
// Make Issue block both as as Draggable and,
// as a DropTarget for other issues being dragged to get the location of drop
useEffect(() => {
@@ -134,7 +138,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
return combine(
draggable({
element,
canDrag: () => !isDragDisabled,
canDrag: () => isDragAllowed,
getInitialData: () => ({ id: issue?.id, type: "ISSUE" }),
onDragStart: () => {
setIsCurrentBlockDragging(true);
@@ -164,15 +168,13 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
if (!issue) return null;
const canEditIssueProperties = canEditProperties(issue.project_id);
return (
<>
<DropIndicator isVisible={!isCurrentBlockDragging && isDraggingOverBlock} />
<div
// 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 })}
onDragStart={() => !isDragDisabled && setIsCurrentBlockDragging(true)}
onDragStart={() => isDragAllowed && setIsCurrentBlockDragging(true)}
>
<ControlLink
id={`issue-${issue.id}`}
@@ -186,7 +188,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
<div
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",
{ "hover:cursor-pointer": !isDragDisabled },
{ "hover:cursor-pointer": isDragAllowed },
{ "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id },
{ "bg-custom-background-80 z-[100]": isCurrentBlockDragging }
)}
@@ -138,7 +138,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
const isGroupByCreatedBy = group_by === "created_by";
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.length > 0 &&
list.map((subList: IGroupByColumn) => {
@@ -1,4 +1,4 @@
import React from "react";
import React, { FC } from "react";
import { observer } from "mobx-react-lite";
import { Circle, ChevronDown, ChevronUp } from "lucide-react";
import { TIssueKanbanFilters } from "@plane/types";
@@ -13,13 +13,14 @@ interface IHeaderSubGroupByCard {
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
}
export const HeaderSubGroupByCard = observer(
({ icon, title, count, column_id, kanbanFilters, handleKanbanFilters }: IHeaderSubGroupByCard) => (
<div className={`relative flex w-full flex-shrink-0 flex-row items-center gap-2 rounded-sm p-1.5`}>
<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"
onClick={() => handleKanbanFilters("sub_group_by", column_id)}
>
export const HeaderSubGroupByCard: FC<IHeaderSubGroupByCard> = observer((props) => {
const { icon, title, count, column_id, kanbanFilters, handleKanbanFilters } = props;
return (
<div
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)}
>
<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) ? (
<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>
</div>
)
);
);
});
@@ -175,23 +175,22 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
<div className="relative h-max min-h-full w-full">
{list &&
list.length > 0 &&
list.map((_list: any) => {
list.map((_list: IGroupByColumn) => {
const subGroupByVisibilityToggle = visibilitySubGroupBy(_list);
if (subGroupByVisibilityToggle.showGroup === false) return <></>;
return (
<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 left-0 flex-shrink-0 bg-custom-background-90 pr-2">
<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">
<HeaderSubGroupByCard
column_id={_list.id}
icon={_list.Icon}
icon={_list.icon}
title={_list.name || ""}
count={calculateIssueCount(_list.id)}
kanbanFilters={kanbanFilters}
handleKanbanFilters={handleKanbanFilters}
/>
</div>
<div className="w-full border-b border-dashed border-custom-border-400" />
</div>
{subGroupByVisibilityToggle.showIssues && (
@@ -313,7 +312,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
return (
<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
issueIds={issueIds}
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 flex-grow items-center gap-3">
<div className="flex w-full truncate">
<div className="flex flex-grow items-center gap-3 truncate">
{displayProperties && displayProperties?.key && (
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
{projectIdentifier}-{issue.sequence_id}
@@ -68,7 +68,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
{issue?.is_draft ? (
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
<span>{issue.name}</span>
<p className="truncate">{issue.name}</p>
</Tooltip>
) : (
<ControlLink
@@ -78,11 +78,11 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
}`}
target="_blank"
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}
>
<Tooltip tooltipContent={issue.name} isMobile={isMobile}>
<span>{issue.name}</span>
<p className="truncate">{issue.name}</p>
</Tooltip>
</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>
<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 ? (
<>
<IssueProperties
@@ -28,7 +28,7 @@ export const IssueBlocksList: FC<Props> = (props) => {
key={`${issueId}`}
defaultHeight="3rem"
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}
>
<IssueBlock
@@ -190,6 +190,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
reset({
...defaultValues,
...(isCreateMoreToggleEnabled ? { ...data } : {}),
project_id: getValues("project_id"),
description_html: data?.description_html ?? "<p></p>",
});
+15 -9
View File
@@ -112,22 +112,28 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
}`}
>
<div className="flex items-center gap-4">
<button onClick={removeRoutePeekId}>
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
<Tooltip tooltipContent="Close the peek view" isMobile={isMobile}>
<button onClick={removeRoutePeekId}>
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
</Tooltip>
<Link href={`/${issueLink}`} onClick={() => removeRoutePeekId()}>
<MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</Link>
<Tooltip tooltipContent="Open issue in full screen" isMobile={isMobile}>
<Link href={`/${issueLink}`} onClick={() => removeRoutePeekId()}>
<MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</Link>
</Tooltip>
{currentMode && (
<div className="flex flex-shrink-0 items-center gap-2">
<CustomSelect
value={currentMode}
onChange={(val: any) => setPeekMode(val)}
customButton={
<button type="button" className="">
<currentMode.icon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
<Tooltip tooltipContent="Toggle peek view layout" isMobile={isMobile}>
<button type="button" className="">
<currentMode.icon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
</Tooltip>
}
>
{PEEK_OPTIONS.map((mode) => (
@@ -43,12 +43,12 @@ export const ArchivedModulesHeader: FC = observer(() => {
const handleFilters = useCallback(
(key: keyof TModuleFilters, value: string | string[]) => {
if (!projectId) return;
const newValues = currentProjectArchivedFilters?.[key] ?? [];
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
else {
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";
// constants
import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters";
// helpers
import { isInDateFormat } from "@/helpers/date-time.helper";
type Props = {
appliedFilters: string[] | null;
@@ -25,6 +27,17 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
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 (
<>
{isDateFilterModalOpen && (
@@ -53,7 +66,7 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
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>
@@ -6,6 +6,8 @@ import { DateFilterModal } from "@/components/core";
import { FilterHeader, FilterOption } from "@/components/issues";
// constants
import { DATE_AFTER_FILTER_OPTIONS } from "@/constants/filters";
// helpers
import { isInDateFormat } from "@/helpers/date-time.helper";
type Props = {
appliedFilters: string[] | null;
@@ -25,6 +27,17 @@ export const FilterTargetDate: React.FC<Props> = observer((props) => {
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 (
<>
{isDateFilterModalOpen && (
@@ -53,7 +66,7 @@ export const FilterTargetDate: React.FC<Props> = observer((props) => {
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>
@@ -5,6 +5,8 @@ import { DateFilterModal } from "@/components/core";
import { FilterHeader, FilterOption } from "@/components/issues";
// constants
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
// helpers
import { isInDateFormat } from "@/helpers/date-time.helper";
type Props = {
appliedFilters: string[] | null;
@@ -24,6 +26,17 @@ export const FilterCreatedDate: React.FC<Props> = observer((props) => {
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 (
<>
{isDateFilterModalOpen && (
@@ -52,7 +65,7 @@ export const FilterCreatedDate: React.FC<Props> = observer((props) => {
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>
@@ -27,6 +27,7 @@ export const PageFiltersSelection: React.FC<Props> = observer((props) => {
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
else if (typeof value === "string") {
if (newValues?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
+13 -8
View File
@@ -35,18 +35,14 @@ export const PageForm: React.FC<Props> = (props) => {
}
};
const isTitleLengthMoreThan255Character = formData.name ? formData.name.length > 255 : false;
return (
<form onSubmit={handlePageFormSubmit}>
<div className="space-y-4">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">Create Page</h3>
<div className="space-y-2">
<div>
<div className="text-custom-text-200">Name</div>
<div className="text-xs text-custom-text-300">
Max length of the name should be less than 255 characters
</div>
</div>
<Input
id="name"
type="text"
@@ -56,8 +52,10 @@ export const PageForm: React.FC<Props> = (props) => {
className="w-full resize-none text-lg"
tabIndex={1}
required
maxLength={255}
/>
{isTitleLengthMoreThan255Character && (
<span className="text-xs text-red-500">Max length of the name should be less than 255 characters</span>
)}
</div>
</div>
@@ -95,7 +93,14 @@ export const PageForm: React.FC<Props> = (props) => {
<Button variant="neutral-primary" size="sm" onClick={handleModalClose} tabIndex={4}>
Cancel
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={5}>
<Button
variant="primary"
size="sm"
type="submit"
loading={isSubmitting}
disabled={isTitleLengthMoreThan255Character}
tabIndex={5}
>
{isSubmitting ? "Creating" : "Create Page"}
</Button>
</div>
@@ -1,13 +1,14 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { AlertTriangle } from "lucide-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
import { IUserLite } from "@plane/types";
// ui
import { Button } from "@plane/ui";
// hooks
import { useProject, useUser } from "@/hooks/store";
type Props = {
data: IUserLite;
@@ -18,10 +19,14 @@ type Props = {
export const ConfirmProjectMemberRemove: React.FC<Props> = observer((props) => {
const { data, onSubmit, isOpen, onClose } = props;
// router
const router = useRouter();
const { projectId } = router.query;
// states
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// store hooks
const { currentUser } = useUser();
const { getProjectById } = useProject();
const handleClose = () => {
onClose();
@@ -36,7 +41,10 @@ export const ConfirmProjectMemberRemove: React.FC<Props> = observer((props) => {
handleClose();
};
if (!projectId) return <></>;
const isCurrentUser = currentUser?.id === data?.id;
const currentProjectDetails = getProjectById(projectId.toString());
return (
<Transition.Root show={isOpen} as={React.Fragment}>
@@ -76,9 +84,19 @@ export const ConfirmProjectMemberRemove: React.FC<Props> = observer((props) => {
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-custom-text-200">
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.
{isCurrentUser ? (
<>
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>
</div>
</div>
@@ -6,7 +6,7 @@ import { FilterHeader, FilterOption } from "@/components/issues";
// constants
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
// helpers
import { isDate } from "@/helpers/date-time.helper";
import { isInDateFormat } from "@/helpers/date-time.helper";
type Props = {
appliedFilters: string[] | null;
@@ -27,7 +27,7 @@ export const FilterCreatedDate: React.FC<Props> = observer((props) => {
);
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;
};
const handleCustomDate = () => {
@@ -35,8 +35,8 @@ export const ProjectFeatureUpdate: FC<Props> = observer((props) => {
<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="text-sm text-custom-text-300 font-medium">
Congrats! Project <ProjectLogo logo={currentProjectDetails.logo_props} /> {currentProjectDetails.name}{" "}
created.
Congrats! Project <ProjectLogo logo={currentProjectDetails.logo_props} />{" "}
<p className="break-all">{currentProjectDetails.name}</p> created.
</div>
<div className="flex gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={1}>
+1 -1
View File
@@ -225,7 +225,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
"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} />
</div>
{!isCollapsed && <p className="truncate text-custom-sidebar-text-200">{project.name}</p>}
+1 -1
View File
@@ -224,7 +224,7 @@ export const getDate = (date: string | Date | undefined | null): Date | undefine
return undefined;
}
};
export const isDate = (date: string) => {
export const isInDateFormat = (date: string) => {
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
return datePattern.test(date);
};
+12 -5
View File
@@ -1,9 +1,9 @@
import { useCallback, useState } from "react";
import { useRouter } from "next/router";
// hooks
import { useUser } from "@/hooks/store";
// types
import { IUser, IUserSettings } from "@plane/types";
import { IUser, IUserSettings, IWorkspace } from "@plane/types";
// hooks
import { useUser, useWorkspace } from "@/hooks/store";
type UseSignInRedirectionProps = {
error: any | null;
@@ -20,6 +20,7 @@ const useSignInRedirection = (): UseSignInRedirectionProps => {
const { next_path } = router.query;
// mobx store
const { fetchCurrentUser, fetchCurrentUserSettings } = useUser();
const { fetchWorkspaces } = useWorkspace();
const isValidURL = (url: string): boolean => {
const disallowedSchemes = /^(https?|ftp):\/\//i;
@@ -47,20 +48,26 @@ const useSignInRedirection = (): UseSignInRedirectionProps => {
// Fetch the current user settings
const userSettings: IUserSettings = await fetchCurrentUserSettings();
const workspacesList: IWorkspace[] = await fetchWorkspaces();
// Extract workspace details
const workspaceSlug =
userSettings?.workspace?.last_workspace_slug || userSettings?.workspace?.fallback_workspace_slug;
// 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");
} catch (error) {
console.error("Error in handleSignInRedirection:", error);
setError(error);
}
},
[fetchCurrentUserSettings, router, next_path]
[fetchCurrentUserSettings, fetchWorkspaces, router, next_path]
);
const updateUserInfo = useCallback(async () => {
+1 -1
View File
@@ -54,13 +54,13 @@
"react-day-picker": "^8.10.0",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-error-boundary": "^4.0.13",
"react-hook-form": "^7.38.0",
"react-markdown": "^8.0.7",
"react-popper": "^2.3.0",
"sharp": "^0.32.1",
"swr": "^2.1.3",
"tailwind-merge": "^2.0.0",
"use-debounce": "^9.0.4",
"uuid": "^9.0.0"
},
"devDependencies": {
+4
View File
@@ -269,6 +269,10 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues {
});
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);
const quickAddIssueIndex = this.issues[cycleId].findIndex((_issueId) => _issueId === data.id);
+4
View File
@@ -274,6 +274,10 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
});
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);
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);
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);
if (quickAddIssueIndex >= 0)
runInAction(() => {
+6
View File
@@ -223,6 +223,12 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues {
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);
if (quickAddIssueIndex >= 0)
runInAction(() => {
+2 -2
View File
@@ -96,7 +96,7 @@ export class LabelStore implements ILabelStore {
const workspaceSlug = this.rootStore.app.router.workspaceSlug || "";
if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
return sortBy(
Object.values(this.labelMap).filter((label) => label.project_id === projectId),
Object.values(this.labelMap).filter((label) => label?.project_id === projectId),
"sort_order"
);
}
@@ -113,7 +113,7 @@ export class LabelStore implements ILabelStore {
const workspaceSlug = this.rootStore.app.router.workspaceSlug || "";
if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
return sortBy(
Object.values(this.labelMap).filter((label) => label.project_id === projectId),
Object.values(this.labelMap).filter((label) => label?.project_id === projectId),
"sort_order"
);
});
+2 -1
View File
@@ -1,5 +1,6 @@
import set from "lodash/set";
import sortBy from "lodash/sortBy";
import update from "lodash/update";
import { action, computed, observable, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// types
@@ -286,7 +287,7 @@ export class ModulesStore implements IModuleStore {
fetchArchivedModuleDetails = async (workspaceSlug: string, projectId: string, moduleId: string) =>
await this.moduleArchiveService.getArchivedModuleDetails(workspaceSlug, projectId, moduleId).then((response) => {
runInAction(() => {
set(this.moduleMap, [moduleId], response);
set(this.moduleMap, [response.id], { ...this.moduleMap?.[response.id], ...response });
});
return response;
});
+8 -6
View File
@@ -2755,7 +2755,7 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@18.2.42", "@types/react@^18.2.42":
"@types/react@*", "@types/react@^18.2.42":
version "18.2.42"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7"
integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA==
@@ -7194,6 +7194,13 @@ react-dropzone@^14.2.3:
file-selector "^0.6.0"
prop-types "^15.8.1"
react-error-boundary@^4.0.13:
version "4.0.13"
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-4.0.13.tgz#80386b7b27b1131c5fbb7368b8c0d983354c7947"
integrity sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==
dependencies:
"@babel/runtime" "^7.12.5"
react-fast-compare@^3.0.1:
version "3.2.2"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
@@ -8628,11 +8635,6 @@ use-callback-ref@^1.3.0:
dependencies:
tslib "^2.0.0"
use-debounce@^9.0.4:
version "9.0.4"
resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-9.0.4.tgz#51d25d856fbdfeb537553972ce3943b897f1ac85"
integrity sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==
use-memo-one@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99"