mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of github.com:makeplane/plane into preview
This commit is contained in:
commit
6240b17063
@ -69,6 +69,9 @@ from .issue import (
|
||||
RelatedIssueSerializer,
|
||||
IssuePublicSerializer,
|
||||
IssueDetailSerializer,
|
||||
IssueReactionLiteSerializer,
|
||||
IssueAttachmentLiteSerializer,
|
||||
IssueLinkLiteSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
|
@ -58,9 +58,12 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
IssueSerializer,
|
||||
LabelSerializer,
|
||||
CycleIssueSerializer,
|
||||
IssueFlatSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueRelationSerializer,
|
||||
InboxIssueLiteSerializer
|
||||
InboxIssueLiteSerializer,
|
||||
IssueReactionLiteSerializer,
|
||||
IssueAttachmentLiteSerializer,
|
||||
IssueLinkLiteSerializer,
|
||||
)
|
||||
|
||||
# Expansion mapper
|
||||
@ -79,12 +82,34 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
"assignees": UserLiteSerializer,
|
||||
"labels": LabelSerializer,
|
||||
"issue_cycle": CycleIssueSerializer,
|
||||
"parent": IssueSerializer,
|
||||
"parent": IssueLiteSerializer,
|
||||
"issue_relation": IssueRelationSerializer,
|
||||
"issue_inbox" : InboxIssueLiteSerializer,
|
||||
"issue_inbox": InboxIssueLiteSerializer,
|
||||
"issue_reactions": IssueReactionLiteSerializer,
|
||||
"issue_attachment": IssueAttachmentLiteSerializer,
|
||||
"issue_link": IssueLinkLiteSerializer,
|
||||
"sub_issues": IssueLiteSerializer,
|
||||
}
|
||||
|
||||
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False)
|
||||
|
||||
self.fields[field] = expansion[field](
|
||||
many=(
|
||||
True
|
||||
if field
|
||||
in [
|
||||
"members",
|
||||
"assignees",
|
||||
"labels",
|
||||
"issue_cycle",
|
||||
"issue_relation",
|
||||
"issue_inbox",
|
||||
"issue_reactions",
|
||||
"issue_attachment",
|
||||
"issue_link",
|
||||
"sub_issues",
|
||||
]
|
||||
else False
|
||||
)
|
||||
)
|
||||
|
||||
return self.fields
|
||||
|
||||
@ -105,7 +130,11 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
LabelSerializer,
|
||||
CycleIssueSerializer,
|
||||
IssueRelationSerializer,
|
||||
InboxIssueLiteSerializer
|
||||
InboxIssueLiteSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueReactionLiteSerializer,
|
||||
IssueAttachmentLiteSerializer,
|
||||
IssueLinkLiteSerializer,
|
||||
)
|
||||
|
||||
# Expansion mapper
|
||||
@ -124,9 +153,13 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
"assignees": UserLiteSerializer,
|
||||
"labels": LabelSerializer,
|
||||
"issue_cycle": CycleIssueSerializer,
|
||||
"parent": IssueSerializer,
|
||||
"parent": IssueLiteSerializer,
|
||||
"issue_relation": IssueRelationSerializer,
|
||||
"issue_inbox" : InboxIssueLiteSerializer,
|
||||
"issue_inbox": InboxIssueLiteSerializer,
|
||||
"issue_reactions": IssueReactionLiteSerializer,
|
||||
"issue_attachment": IssueAttachmentLiteSerializer,
|
||||
"issue_link": IssueLinkLiteSerializer,
|
||||
"sub_issues": IssueLiteSerializer,
|
||||
}
|
||||
# Check if field in expansion then expand the field
|
||||
if expand in expansion:
|
||||
|
@ -444,6 +444,22 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
return IssueLink.objects.create(**validated_data)
|
||||
|
||||
|
||||
class IssueLinkLiteSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = IssueLink
|
||||
fields = [
|
||||
"id",
|
||||
"issue_id",
|
||||
"title",
|
||||
"url",
|
||||
"metadata",
|
||||
"created_by_id",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IssueAttachmentSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
@ -459,6 +475,21 @@ class IssueAttachmentSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = IssueAttachment
|
||||
fields = [
|
||||
"id",
|
||||
"asset",
|
||||
"attributes",
|
||||
"issue_id",
|
||||
"updated_at",
|
||||
"updated_by_id",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IssueReactionSerializer(BaseSerializer):
|
||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||
|
||||
@ -473,6 +504,18 @@ class IssueReactionSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class IssueReactionLiteSerializer(DynamicBaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = IssueReaction
|
||||
fields = [
|
||||
"id",
|
||||
"actor_id",
|
||||
"issue_id",
|
||||
"reaction",
|
||||
]
|
||||
|
||||
|
||||
class CommentReactionSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = CommentReaction
|
||||
@ -558,15 +601,15 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
# ids
|
||||
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
module_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(), required=False, allow_null=True
|
||||
child=serializers.UUIDField(), required=False,
|
||||
)
|
||||
|
||||
# Many to many
|
||||
label_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(), required=False, allow_null=True
|
||||
child=serializers.UUIDField(), required=False,
|
||||
)
|
||||
assignee_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(), required=False, allow_null=True
|
||||
child=serializers.UUIDField(), required=False,
|
||||
)
|
||||
|
||||
# Count items
|
||||
@ -606,48 +649,39 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
|
||||
class IssueDetailSerializer(IssueSerializer):
|
||||
description_html = serializers.CharField()
|
||||
is_subscribed = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta(IssueSerializer.Meta):
|
||||
fields = IssueSerializer.Meta.fields + ["description_html", "is_subscribed"]
|
||||
fields = IssueSerializer.Meta.fields + [
|
||||
"description_html",
|
||||
"is_subscribed",
|
||||
]
|
||||
|
||||
|
||||
class IssueLiteSerializer(DynamicBaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(
|
||||
read_only=True, source="workspace"
|
||||
)
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
state_detail = StateLiteSerializer(read_only=True, source="state")
|
||||
label_details = LabelLiteSerializer(
|
||||
read_only=True, source="labels", many=True
|
||||
)
|
||||
assignee_details = UserLiteSerializer(
|
||||
read_only=True, source="assignees", many=True
|
||||
)
|
||||
sub_issues_count = serializers.IntegerField(read_only=True)
|
||||
cycle_id = serializers.UUIDField(read_only=True)
|
||||
module_id = serializers.UUIDField(read_only=True)
|
||||
attachment_count = serializers.IntegerField(read_only=True)
|
||||
link_count = serializers.IntegerField(read_only=True)
|
||||
issue_reactions = IssueReactionSerializer(read_only=True, many=True)
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"start_date",
|
||||
"target_date",
|
||||
"completed_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
fields = [
|
||||
"id",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IssueDetailSerializer(IssueSerializer):
|
||||
description_html = serializers.CharField()
|
||||
is_subscribed = serializers.BooleanField()
|
||||
|
||||
class Meta(IssueSerializer.Meta):
|
||||
fields = IssueSerializer.Meta.fields + [
|
||||
"description_html",
|
||||
"is_subscribed",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IssuePublicSerializer(BaseSerializer):
|
||||
|
@ -3,7 +3,7 @@ import json
|
||||
|
||||
# Django import
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch
|
||||
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Exists
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
@ -25,13 +25,14 @@ from plane.db.models import (
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
ProjectMember,
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
IssueCreateSerializer,
|
||||
IssueSerializer,
|
||||
InboxSerializer,
|
||||
InboxIssueSerializer,
|
||||
IssueCreateSerializer,
|
||||
IssueDetailSerializer,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
@ -295,11 +296,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
issue_data = request.data.pop("issue", False)
|
||||
|
||||
if bool(issue_data):
|
||||
issue = Issue.objects.get(
|
||||
pk=inbox_issue.issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
issue = self.get_queryset().filter(pk=inbox_issue.issue_id).first()
|
||||
# Only allow guests and viewers to edit name and description
|
||||
if project_member.role <= 10:
|
||||
# viewers and guests since only viewers and guests
|
||||
@ -385,9 +382,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
if state is not None:
|
||||
issue.state = state
|
||||
issue.save()
|
||||
issue = self.get_queryset().filter(pk=issue_id).first()
|
||||
serializer = IssueSerializer(issue, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
@ -397,11 +392,45 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
|
||||
issue = self.get_queryset().filter(pk=issue_id).first()
|
||||
serializer = IssueDetailSerializer(
|
||||
issue,
|
||||
expand=self.expand,
|
||||
)
|
||||
issue = (
|
||||
self.get_queryset()
|
||||
.filter(pk=issue_id)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related(
|
||||
"issue", "actor"
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_attachment",
|
||||
queryset=IssueAttachment.objects.select_related("issue"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
queryset=IssueLink.objects.select_related("created_by"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
is_subscribed=Exists(
|
||||
IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=OuterRef("pk"),
|
||||
subscriber=request.user,
|
||||
)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if issue is None:
|
||||
return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = IssueSerializer(issue)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, slug, project_id, inbox_id, issue_id):
|
||||
|
@ -528,21 +528,62 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
issue = self.get_queryset().filter(pk=pk).first()
|
||||
return Response(
|
||||
IssueDetailSerializer(
|
||||
issue, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
issue = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related(
|
||||
"issue", "actor"
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_attachment",
|
||||
queryset=IssueAttachment.objects.select_related("issue"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
queryset=IssueLink.objects.select_related("created_by"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
is_subscribed=Exists(
|
||||
IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=OuterRef("pk"),
|
||||
subscriber=request.user,
|
||||
)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if not issue:
|
||||
return Response(
|
||||
{"error": "The required object does not exist."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
serializer = IssueDetailSerializer(issue, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
issue = self.get_queryset().filter(pk=pk).first()
|
||||
|
||||
if not issue:
|
||||
return Response(
|
||||
{"error": "Issue not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
current_instance = json.dumps(
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
||||
serializer = IssueCreateSerializer(
|
||||
issue, data=request.data, partial=True
|
||||
@ -560,39 +601,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.values(
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return Response(issue, status=status.HTTP_200_OK)
|
||||
issue = self.get_queryset().filter(pk=pk).first()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def destroy(self, request, slug, project_id, pk=None):
|
||||
@ -1581,13 +1591,47 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
issue = self.get_queryset().filter(pk=pk).first()
|
||||
return Response(
|
||||
IssueDetailSerializer(
|
||||
issue, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
issue = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related(
|
||||
"issue", "actor"
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_attachment",
|
||||
queryset=IssueAttachment.objects.select_related("issue"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
queryset=IssueLink.objects.select_related("created_by"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
is_subscribed=Exists(
|
||||
IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=OuterRef("pk"),
|
||||
subscriber=request.user,
|
||||
)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if not issue:
|
||||
return Response(
|
||||
{"error": "The required object does not exist."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
serializer = IssueDetailSerializer(issue, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def unarchive(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(
|
||||
@ -2258,9 +2302,14 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
issue = self.get_queryset().filter(pk=pk).first()
|
||||
|
||||
if not issue:
|
||||
return Response(
|
||||
{"error": "Issue does not exist"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
serializer = IssueSerializer(issue, data=request.data, partial=True)
|
||||
|
||||
if serializer.is_valid():
|
||||
@ -2286,17 +2335,52 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
issue = self.get_queryset().filter(pk=pk).first()
|
||||
return Response(
|
||||
IssueSerializer(
|
||||
issue, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
issue = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related(
|
||||
"issue", "actor"
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_attachment",
|
||||
queryset=IssueAttachment.objects.select_related("issue"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
queryset=IssueLink.objects.select_related("created_by"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
is_subscribed=Exists(
|
||||
IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=OuterRef("pk"),
|
||||
subscriber=request.user,
|
||||
)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if not issue:
|
||||
return Response(
|
||||
{"error": "The required object does not exist."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
serializer = IssueDetailSerializer(issue, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(
|
||||
|
@ -560,7 +560,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
.get_queryset()
|
||||
.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
.select_related("workspace", "workspace__owner")
|
||||
@ -768,7 +767,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
|
||||
project_ids = (
|
||||
ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
.values_list("project_id", flat=True)
|
||||
@ -778,7 +776,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
|
||||
# Get all the project members in which the user is involved
|
||||
project_members = ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
project_id__in=project_ids,
|
||||
is_active=True,
|
||||
).select_related("project", "member", "workspace")
|
||||
|
@ -10,6 +10,7 @@ from django.utils import timezone
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import EmailNotificationLog, User, Issue
|
||||
@ -301,5 +302,7 @@ def send_email_notification(
|
||||
print("Duplicate task recived. Skipping...")
|
||||
return
|
||||
except (Issue.DoesNotExist, User.DoesNotExist) as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
|
@ -97,8 +97,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3 }).run();
|
||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run();
|
||||
};
|
||||
|
||||
export const unsetLinkEditor = (editor: Editor) => {
|
||||
|
@ -170,68 +170,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
}
|
||||
}
|
||||
|
||||
#editor-container {
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin: 0.5em 0 0.5em 0;
|
||||
|
||||
border: 1px solid rgb(var(--color-border-200));
|
||||
width: 100%;
|
||||
|
||||
td,
|
||||
th {
|
||||
min-width: 1em;
|
||||
border: 1px solid rgb(var(--color-border-200));
|
||||
padding: 10px 15px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
> * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
background-color: rgb(var(--color-primary-100));
|
||||
}
|
||||
|
||||
td:hover {
|
||||
background-color: rgba(var(--color-primary-300), 0.1);
|
||||
}
|
||||
|
||||
.selectedCell:after {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
content: "";
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(var(--color-primary-300), 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 0;
|
||||
bottom: -2px;
|
||||
width: 2px;
|
||||
background-color: rgb(var(--color-primary-400));
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
|
@ -9,15 +9,15 @@
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin: 0;
|
||||
margin-bottom: 3rem;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
margin-bottom: 1rem;
|
||||
border: 2px solid rgba(var(--color-border-300));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tableWrapper table td,
|
||||
.tableWrapper table th {
|
||||
min-width: 1em;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
border: 1px solid rgba(var(--color-border-300));
|
||||
padding: 10px 15px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
@ -43,7 +43,8 @@
|
||||
.tableWrapper table th {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
background-color: rgba(var(--color-primary-100));
|
||||
background-color: #d9e4ff;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
.tableWrapper table th * {
|
||||
@ -62,6 +63,35 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.colorPicker {
|
||||
display: grid;
|
||||
padding: 8px 8px;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.colorPickerLabel {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
padding: 8px 8px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.colorPickerItem {
|
||||
margin: 2px 0px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.divider {
|
||||
background-color: #e5e7eb;
|
||||
height: 1px;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.tableWrapper table .column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
@ -69,7 +99,7 @@
|
||||
bottom: -2px;
|
||||
width: 4px;
|
||||
z-index: 99;
|
||||
background-color: rgba(var(--color-primary-400));
|
||||
background-color: #d9e4ff;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@ -112,7 +142,7 @@
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .rowsControlDiv {
|
||||
background-color: rgba(var(--color-primary-100));
|
||||
background-color: #d9e4ff;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
border-radius: 2px;
|
||||
background-size: 1.25rem;
|
||||
@ -127,7 +157,7 @@
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .columnsControlDiv {
|
||||
background-color: rgba(var(--color-primary-100));
|
||||
background-color: #d9e4ff;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
border-radius: 2px;
|
||||
background-size: 1.25rem;
|
||||
@ -144,10 +174,12 @@
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox {
|
||||
border: 1px solid rgba(var(--color-border-300));
|
||||
background-color: rgba(var(--color-background-100));
|
||||
border-radius: 5px;
|
||||
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 200px;
|
||||
width: max-content;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
@ -158,7 +190,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: none;
|
||||
padding: 0.1rem;
|
||||
padding: 0.3rem 0.5rem 0.1rem 0.1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
@ -173,9 +205,7 @@
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer,
|
||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer,
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer {
|
||||
border: 1px solid rgba(var(--color-border-300));
|
||||
border-radius: 3px;
|
||||
padding: 4px;
|
||||
padding: 4px 0px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -187,8 +217,8 @@
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg,
|
||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg,
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.tableToolbox {
|
||||
|
@ -13,7 +13,7 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||
};
|
||||
},
|
||||
|
||||
content: "paragraph+",
|
||||
content: "block+",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
@ -33,7 +33,10 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "none",
|
||||
default: null,
|
||||
},
|
||||
textColor: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
@ -50,7 +53,7 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||
return [
|
||||
"td",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: `background-color: ${node.attrs.background}`,
|
||||
style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`,
|
||||
}),
|
||||
0,
|
||||
];
|
||||
|
@ -33,7 +33,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "rgb(var(--color-primary-100))",
|
||||
default: "none",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@ -13,6 +13,17 @@ export const TableRow = Node.create<TableRowOptions>({
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
background: {
|
||||
default: null,
|
||||
},
|
||||
textColor: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
content: "(tableCell | tableHeader)*",
|
||||
|
||||
tableRole: "row",
|
||||
@ -22,6 +33,12 @@ export const TableRow = Node.create<TableRowOptions>({
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["tr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||
const style = HTMLAttributes.background
|
||||
? `background-color: ${HTMLAttributes.background}; color: ${HTMLAttributes.textColor}`
|
||||
: "";
|
||||
|
||||
const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style });
|
||||
|
||||
return ["tr", attributes, 0];
|
||||
},
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
export const icons = {
|
||||
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" length="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
|
||||
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`,
|
||||
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" length="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`,
|
||||
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
|
||||
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
|
||||
insertLeftTableIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
length={24}
|
||||
@ -35,6 +35,8 @@ export const icons = {
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
toggleColumnHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||
toggleRowHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||
insertBottomTableIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
length={24}
|
||||
|
@ -81,53 +81,75 @@ const defaultTippyOptions: Partial<Props> = {
|
||||
placement: "right",
|
||||
};
|
||||
|
||||
function setCellsBackgroundColor(editor: Editor, backgroundColor: string) {
|
||||
function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
|
||||
return editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes("tableCell", {
|
||||
background: backgroundColor,
|
||||
})
|
||||
.updateAttributes("tableHeader", {
|
||||
background: backgroundColor,
|
||||
background: color.backgroundColor,
|
||||
textColor: color.textColor,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
|
||||
const { state, dispatch } = editor.view;
|
||||
const { selection } = state;
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the position of the hovered cell in the selection to determine the row.
|
||||
const hoveredCell = selection.$headCell || selection.$anchorCell;
|
||||
|
||||
// Find the depth of the table row node
|
||||
let rowDepth = hoveredCell.depth;
|
||||
while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") {
|
||||
rowDepth--;
|
||||
}
|
||||
|
||||
// If we couldn't find a tableRow node, we can't set the background color
|
||||
if (hoveredCell.node(rowDepth).type.name !== "tableRow") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the position where the table row starts
|
||||
const rowStartPos = hoveredCell.start(rowDepth);
|
||||
|
||||
// Create a transaction that sets the background color on the tableRow node.
|
||||
const tr = state.tr.setNodeMarkup(rowStartPos - 1, null, {
|
||||
...hoveredCell.node(rowDepth).attrs,
|
||||
background: color.backgroundColor,
|
||||
textColor: color.textColor,
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
const columnsToolboxItems: ToolboxItem[] = [
|
||||
{
|
||||
label: "Add Column Before",
|
||||
label: "Toggle column header",
|
||||
icon: icons.toggleColumnHeader,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderColumn().run(),
|
||||
},
|
||||
{
|
||||
label: "Add column before",
|
||||
icon: icons.insertLeftTableIcon,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(),
|
||||
},
|
||||
{
|
||||
label: "Add Column After",
|
||||
label: "Add column after",
|
||||
icon: icons.insertRightTableIcon,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(),
|
||||
},
|
||||
{
|
||||
label: "Pick Column Color",
|
||||
icon: icons.colorPicker,
|
||||
action: ({
|
||||
editor,
|
||||
triggerButton,
|
||||
controlsContainer,
|
||||
}: {
|
||||
editor: Editor;
|
||||
triggerButton: HTMLElement;
|
||||
controlsContainer: Element;
|
||||
}) => {
|
||||
createColorPickerToolbox({
|
||||
triggerButton,
|
||||
tippyOptions: {
|
||||
appendTo: controlsContainer,
|
||||
},
|
||||
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
|
||||
});
|
||||
},
|
||||
label: "Pick color",
|
||||
icon: "", // No icon needed for color picker
|
||||
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
|
||||
},
|
||||
{
|
||||
label: "Delete Column",
|
||||
label: "Delete column",
|
||||
icon: icons.deleteColumn,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(),
|
||||
},
|
||||
@ -135,35 +157,24 @@ const columnsToolboxItems: ToolboxItem[] = [
|
||||
|
||||
const rowsToolboxItems: ToolboxItem[] = [
|
||||
{
|
||||
label: "Add Row Above",
|
||||
label: "Toggle row header",
|
||||
icon: icons.toggleRowHeader,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderRow().run(),
|
||||
},
|
||||
{
|
||||
label: "Add row above",
|
||||
icon: icons.insertTopTableIcon,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(),
|
||||
},
|
||||
{
|
||||
label: "Add Row Below",
|
||||
label: "Add row below",
|
||||
icon: icons.insertBottomTableIcon,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(),
|
||||
},
|
||||
{
|
||||
label: "Pick Row Color",
|
||||
icon: icons.colorPicker,
|
||||
action: ({
|
||||
editor,
|
||||
triggerButton,
|
||||
controlsContainer,
|
||||
}: {
|
||||
editor: Editor;
|
||||
triggerButton: HTMLButtonElement;
|
||||
controlsContainer: Element | "parent" | ((ref: Element) => Element) | undefined;
|
||||
}) => {
|
||||
createColorPickerToolbox({
|
||||
triggerButton,
|
||||
tippyOptions: {
|
||||
appendTo: controlsContainer,
|
||||
},
|
||||
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
|
||||
});
|
||||
},
|
||||
label: "Pick color",
|
||||
icon: "",
|
||||
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
|
||||
},
|
||||
{
|
||||
label: "Delete Row",
|
||||
@ -176,37 +187,57 @@ function createToolbox({
|
||||
triggerButton,
|
||||
items,
|
||||
tippyOptions,
|
||||
onSelectColor,
|
||||
onClickItem,
|
||||
colors,
|
||||
}: {
|
||||
triggerButton: Element | null;
|
||||
items: ToolboxItem[];
|
||||
tippyOptions: any;
|
||||
onClickItem: (item: ToolboxItem) => void;
|
||||
onSelectColor: (color: { backgroundColor: string; textColor: string }) => void;
|
||||
colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } };
|
||||
}): Instance<Props> {
|
||||
// @ts-expect-error
|
||||
const toolbox = tippy(triggerButton, {
|
||||
content: h(
|
||||
"div",
|
||||
{ className: "tableToolbox" },
|
||||
items.map((item) =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className: "toolboxItem",
|
||||
itemType: "button",
|
||||
onClick() {
|
||||
onClickItem(item);
|
||||
items.map((item, index) => {
|
||||
if (item.label === "Pick color") {
|
||||
return h("div", { className: "flex flex-col" }, [
|
||||
h("div", { className: "divider" }),
|
||||
h("div", { className: "colorPickerLabel" }, item.label),
|
||||
h(
|
||||
"div",
|
||||
{ className: "colorPicker grid" },
|
||||
Object.entries(colors).map(([colorName, colorValue]) =>
|
||||
h("div", {
|
||||
className: "colorPickerItem",
|
||||
style: `background-color: ${colorValue.backgroundColor};
|
||||
color: ${colorValue.textColor || "inherit"};`,
|
||||
innerHTML: colorValue?.icon || "",
|
||||
onClick: () => onSelectColor(colorValue),
|
||||
})
|
||||
)
|
||||
),
|
||||
h("div", { className: "divider" }),
|
||||
]);
|
||||
} else {
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
className: "toolboxItem",
|
||||
itemType: "div",
|
||||
onClick: () => onClickItem(item),
|
||||
},
|
||||
},
|
||||
[
|
||||
h("div", {
|
||||
className: "iconContainer",
|
||||
innerHTML: item.icon,
|
||||
}),
|
||||
h("div", { className: "label" }, item.label),
|
||||
]
|
||||
)
|
||||
)
|
||||
[
|
||||
h("div", { className: "iconContainer", innerHTML: item.icon }),
|
||||
h("div", { className: "label" }, item.label),
|
||||
]
|
||||
);
|
||||
}
|
||||
})
|
||||
),
|
||||
...tippyOptions,
|
||||
});
|
||||
@ -214,71 +245,6 @@ function createToolbox({
|
||||
return Array.isArray(toolbox) ? toolbox[0] : toolbox;
|
||||
}
|
||||
|
||||
function createColorPickerToolbox({
|
||||
triggerButton,
|
||||
tippyOptions,
|
||||
onSelectColor = () => {},
|
||||
}: {
|
||||
triggerButton: HTMLElement;
|
||||
tippyOptions: Partial<Props>;
|
||||
onSelectColor?: (color: string) => void;
|
||||
}) {
|
||||
const items = {
|
||||
Default: "rgb(var(--color-primary-100))",
|
||||
Orange: "#FFE5D1",
|
||||
Grey: "#F1F1F1",
|
||||
Yellow: "#FEF3C7",
|
||||
Green: "#DCFCE7",
|
||||
Red: "#FFDDDD",
|
||||
Blue: "#D9E4FF",
|
||||
Pink: "#FFE8FA",
|
||||
Purple: "#E8DAFB",
|
||||
};
|
||||
|
||||
const colorPicker = tippy(triggerButton, {
|
||||
...defaultTippyOptions,
|
||||
content: h(
|
||||
"div",
|
||||
{ className: "tableColorPickerToolbox" },
|
||||
Object.entries(items).map(([key, value]) =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className: "toolboxItem",
|
||||
itemType: "button",
|
||||
onClick: () => {
|
||||
onSelectColor(value);
|
||||
colorPicker.hide();
|
||||
},
|
||||
},
|
||||
[
|
||||
h("div", {
|
||||
className: "colorContainer",
|
||||
style: {
|
||||
backgroundColor: value,
|
||||
},
|
||||
}),
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className: "label",
|
||||
},
|
||||
key
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
onHidden: (instance) => {
|
||||
instance.destroy();
|
||||
},
|
||||
showOnCreate: true,
|
||||
...tippyOptions,
|
||||
});
|
||||
|
||||
return colorPicker;
|
||||
}
|
||||
|
||||
export class TableView implements NodeView {
|
||||
node: ProseMirrorNode;
|
||||
cellMinWidth: number;
|
||||
@ -347,10 +313,27 @@ export class TableView implements NodeView {
|
||||
this.rowsControl,
|
||||
this.columnsControl
|
||||
);
|
||||
const columnColors = {
|
||||
Blue: { backgroundColor: "#D9E4FF", textColor: "#171717" },
|
||||
Orange: { backgroundColor: "#FFEDD5", textColor: "#171717" },
|
||||
Grey: { backgroundColor: "#F1F1F1", textColor: "#171717" },
|
||||
Yellow: { backgroundColor: "#FEF3C7", textColor: "#171717" },
|
||||
Green: { backgroundColor: "#DCFCE7", textColor: "#171717" },
|
||||
Red: { backgroundColor: "#FFDDDD", textColor: "#171717" },
|
||||
Pink: { backgroundColor: "#FFE8FA", textColor: "#171717" },
|
||||
Purple: { backgroundColor: "#E8DAFB", textColor: "#171717" },
|
||||
None: {
|
||||
backgroundColor: "none",
|
||||
textColor: "none",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="gray" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>`,
|
||||
},
|
||||
};
|
||||
|
||||
this.columnsToolbox = createToolbox({
|
||||
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
|
||||
items: columnsToolboxItems,
|
||||
colors: columnColors,
|
||||
onSelectColor: (color) => setCellsBackgroundColor(this.editor, color),
|
||||
tippyOptions: {
|
||||
...defaultTippyOptions,
|
||||
appendTo: this.controls,
|
||||
@ -368,10 +351,12 @@ export class TableView implements NodeView {
|
||||
this.rowsToolbox = createToolbox({
|
||||
triggerButton: this.rowsControl.firstElementChild,
|
||||
items: rowsToolboxItems,
|
||||
colors: columnColors,
|
||||
tippyOptions: {
|
||||
...defaultTippyOptions,
|
||||
appendTo: this.controls,
|
||||
},
|
||||
onSelectColor: (color) => setTableRowBackgroundColor(editor, color),
|
||||
onClickItem: (item) => {
|
||||
item.action({
|
||||
editor: this.editor,
|
||||
@ -383,8 +368,6 @@ export class TableView implements NodeView {
|
||||
});
|
||||
}
|
||||
|
||||
// Table
|
||||
|
||||
this.colgroup = h(
|
||||
"colgroup",
|
||||
null,
|
||||
@ -437,16 +420,19 @@ export class TableView implements NodeView {
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => {
|
||||
if (curr.spec.hoveredCell !== undefined) {
|
||||
acc["hoveredCell"] = curr.spec.hoveredCell;
|
||||
}
|
||||
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce(
|
||||
(acc, curr) => {
|
||||
if (curr.spec.hoveredCell !== undefined) {
|
||||
acc["hoveredCell"] = curr.spec.hoveredCell;
|
||||
}
|
||||
|
||||
if (curr.spec.hoveredTable !== undefined) {
|
||||
acc["hoveredTable"] = curr.spec.hoveredTable;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, HTMLElement>) as any;
|
||||
if (curr.spec.hoveredTable !== undefined) {
|
||||
acc["hoveredTable"] = curr.spec.hoveredTable;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, HTMLElement>
|
||||
) as any;
|
||||
|
||||
if (table === undefined || cell === undefined) {
|
||||
return this.root.classList.add("controls--disabled");
|
||||
@ -457,12 +443,12 @@ export class TableView implements NodeView {
|
||||
|
||||
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement;
|
||||
|
||||
if (!this.table) {
|
||||
if (!this.table || !cellDom) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tableRect = this.table.getBoundingClientRect();
|
||||
const cellRect = cellDom.getBoundingClientRect();
|
||||
const tableRect = this.table?.getBoundingClientRect();
|
||||
const cellRect = cellDom?.getBoundingClientRect();
|
||||
|
||||
if (this.columnsControl) {
|
||||
this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`;
|
||||
|
@ -107,10 +107,9 @@ export const Table = Node.create({
|
||||
addCommands() {
|
||||
return {
|
||||
insertTable:
|
||||
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
|
||||
({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>
|
||||
({ tr, dispatch, editor }) => {
|
||||
const node = createTable(editor.schema, rows, cols, withHeaderRow);
|
||||
|
||||
if (dispatch) {
|
||||
const offset = tr.selection.anchor + 1;
|
||||
|
||||
|
@ -42,15 +42,6 @@ export function CoreEditorProps(
|
||||
return false;
|
||||
},
|
||||
handleDrop: (view, event, _slice, moved) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const selection: any = window?.getSelection();
|
||||
if (selection.rangeCount !== 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
if (findTableAncestor(range.startContainer)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
|
@ -48,34 +48,12 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||
function getComplexItems(): BubbleMenuItem[] {
|
||||
const items: BubbleMenuItem[] = [TableItem(editor)];
|
||||
|
||||
if (shouldShowImageItem()) {
|
||||
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
|
||||
}
|
||||
|
||||
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
|
||||
return items;
|
||||
}
|
||||
|
||||
const complexItems: BubbleMenuItem[] = getComplexItems();
|
||||
|
||||
function shouldShowImageItem(): boolean {
|
||||
if (typeof window !== "undefined") {
|
||||
const selectionRange: any = window?.getSelection();
|
||||
const { selection } = props.editor.state;
|
||||
|
||||
if (selectionRange.rangeCount !== 0) {
|
||||
const range = selectionRange.getRangeAt(0);
|
||||
if (findTableAncestor(range.startContainer)) {
|
||||
return false;
|
||||
}
|
||||
if (isCellSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
||||
<div className="flex items-center gap-0.5 pr-2">
|
||||
|
@ -35,7 +35,7 @@ export interface DragHandleOptions {
|
||||
}
|
||||
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
const data = node?.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: data.top,
|
||||
@ -65,7 +65,7 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
||||
}
|
||||
|
||||
function nodePosAtDOM(node: Element, view: EditorView) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
const boundingRect = node?.getBoundingClientRect();
|
||||
|
||||
if (node.nodeName === "IMG") {
|
||||
return view.posAtCoords({
|
||||
|
@ -60,34 +60,13 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||
function getComplexItems(): BubbleMenuItem[] {
|
||||
const items: BubbleMenuItem[] = [TableItem(props.editor)];
|
||||
|
||||
if (shouldShowImageItem()) {
|
||||
items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting));
|
||||
}
|
||||
items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const complexItems: BubbleMenuItem[] = getComplexItems();
|
||||
|
||||
function shouldShowImageItem(): boolean {
|
||||
if (typeof window !== "undefined") {
|
||||
const selectionRange: any = window?.getSelection();
|
||||
const { selection } = props.editor.state;
|
||||
|
||||
if (selectionRange.rangeCount !== 0) {
|
||||
const range = selectionRange.getRangeAt(0);
|
||||
if (findTableAncestor(range.startContainer)) {
|
||||
return false;
|
||||
}
|
||||
if (isCellSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const handleAccessChange = (accessKey: string) => {
|
||||
props.commentAccessSpecifier?.onAccessChange(accessKey);
|
||||
};
|
||||
|
2
packages/types/src/cycles.d.ts
vendored
2
packages/types/src/cycles.d.ts
vendored
@ -30,7 +30,7 @@ export interface ICycle {
|
||||
is_favorite: boolean;
|
||||
issue: string;
|
||||
name: string;
|
||||
owned_by: string;
|
||||
owned_by_id: string;
|
||||
progress_snapshot: TProgressSnapshot;
|
||||
project_id: string;
|
||||
status: TCycleGroups;
|
||||
|
10
packages/types/src/issues/issue.d.ts
vendored
10
packages/types/src/issues/issue.d.ts
vendored
@ -1,4 +1,7 @@
|
||||
import { TIssuePriorities } from "../issues";
|
||||
import { TIssueAttachment } from "./issue_attachment";
|
||||
import { TIssueLink } from "./issue_link";
|
||||
import { TIssueReaction } from "./issue_reaction";
|
||||
|
||||
// new issue structure types
|
||||
export type TIssue = {
|
||||
@ -34,7 +37,12 @@ export type TIssue = {
|
||||
updated_by: string;
|
||||
|
||||
is_draft: boolean;
|
||||
is_subscribed: boolean;
|
||||
is_subscribed?: boolean;
|
||||
|
||||
parent?: partial<TIssue>;
|
||||
issue_reactions?: TIssueReaction[];
|
||||
issue_attachment?: TIssueAttachment[];
|
||||
issue_link?: TIssueLink[];
|
||||
|
||||
// tempId is used for optimistic updates. It is not a part of the API response.
|
||||
tempId?: string;
|
||||
|
10
packages/types/src/issues/issue_attachment.d.ts
vendored
10
packages/types/src/issues/issue_attachment.d.ts
vendored
@ -1,17 +1,15 @@
|
||||
export type TIssueAttachment = {
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
attributes: {
|
||||
name: string;
|
||||
size: number;
|
||||
};
|
||||
asset: string;
|
||||
created_by: string;
|
||||
issue_id: string;
|
||||
|
||||
//need
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
project: string;
|
||||
workspace: string;
|
||||
issue: string;
|
||||
};
|
||||
|
||||
export type TIssueAttachmentMap = {
|
||||
|
8
packages/types/src/issues/issue_link.d.ts
vendored
8
packages/types/src/issues/issue_link.d.ts
vendored
@ -4,11 +4,13 @@ export type TIssueLinkEditableFields = {
|
||||
};
|
||||
|
||||
export type TIssueLink = TIssueLinkEditableFields & {
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
created_by_detail: IUserLite;
|
||||
created_by_id: string;
|
||||
id: string;
|
||||
metadata: any;
|
||||
issue_id: string;
|
||||
|
||||
//need
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
export type TIssueLinkMap = {
|
||||
|
11
packages/types/src/issues/issue_reaction.d.ts
vendored
11
packages/types/src/issues/issue_reaction.d.ts
vendored
@ -1,15 +1,8 @@
|
||||
export type TIssueReaction = {
|
||||
actor: string;
|
||||
actor_detail: IUserLite;
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
actor_id: string;
|
||||
id: string;
|
||||
issue: string;
|
||||
project: string;
|
||||
issue_id: string;
|
||||
reaction: string;
|
||||
updated_at: Date;
|
||||
updated_by: string;
|
||||
workspace: string;
|
||||
};
|
||||
|
||||
export type TIssueReactionMap = {
|
||||
|
@ -20,7 +20,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
|
||||
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
|
||||
const moduleLeadDetails = moduleDetails && moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined;
|
||||
|
||||
return (
|
||||
|
@ -69,7 +69,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
);
|
||||
|
||||
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
|
||||
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by) : undefined;
|
||||
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
|
||||
|
||||
const { data: activeCycleIssues } = useSWR(
|
||||
workspaceSlug && projectId && currentProjectActiveCycleId
|
||||
|
@ -59,7 +59,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
|
||||
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
// form info
|
||||
|
@ -16,12 +16,13 @@ import { CYCLE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
handleClick: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const cycleService = new CycleService();
|
||||
|
||||
export const TransferIssues: React.FC<Props> = (props) => {
|
||||
const { handleClick } = props;
|
||||
const { handleClick, disabled = false } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
@ -46,7 +47,12 @@ export const TransferIssues: React.FC<Props> = (props) => {
|
||||
|
||||
{isEmpty(cycleDetails?.progress_snapshot) && transferableIssuesCount > 0 && (
|
||||
<div>
|
||||
<Button variant="primary" prependIcon={<TransferIcon color="white" />} onClick={handleClick}>
|
||||
<Button
|
||||
variant="primary"
|
||||
prependIcon={<TransferIcon color="white" />}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
Transfer Issues
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -14,7 +14,7 @@ import {
|
||||
IssueListItemProps,
|
||||
} from "components/dashboard/widgets";
|
||||
// ui
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
import { Loader, getButtonStyling } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { getRedirectionFilters } from "helpers/dashboard.helper";
|
||||
@ -63,7 +63,12 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||
<>
|
||||
<div className="h-full">
|
||||
{isLoading ? (
|
||||
<></>
|
||||
<Loader className="space-y-4 mx-6 mt-7">
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
</Loader>
|
||||
) : issues.length > 0 ? (
|
||||
<>
|
||||
<div className="mt-7 mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-6 gap-1 text-xs text-custom-text-300 pb-1">
|
||||
|
106
web/components/gantt-chart/blocks/block.tsx
Normal file
106
web/components/gantt-chart/blocks/block.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useGanttChart } from "../hooks";
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
// components
|
||||
import { ChartAddBlock, ChartDraggable } from "../helpers";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT } from "../constants";
|
||||
|
||||
type Props = {
|
||||
block: IGanttBlock;
|
||||
blockToRender: (data: any) => React.ReactNode;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
enableBlockLeftResize: boolean;
|
||||
enableBlockRightResize: boolean;
|
||||
enableBlockMove: boolean;
|
||||
enableAddBlock: boolean;
|
||||
ganttContainerRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
block,
|
||||
blockToRender,
|
||||
blockUpdateHandler,
|
||||
enableBlockLeftResize,
|
||||
enableBlockRightResize,
|
||||
enableBlockMove,
|
||||
enableAddBlock,
|
||||
ganttContainerRef,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { updateActiveBlockId, isBlockActive } = useGanttChart();
|
||||
const { peekIssue } = useIssueDetail();
|
||||
|
||||
const isBlockVisibleOnChart = block.start_date && block.target_date;
|
||||
|
||||
const handleChartBlockPosition = (
|
||||
block: IGanttBlock,
|
||||
totalBlockShifts: number,
|
||||
dragDirection: "left" | "right" | "move"
|
||||
) => {
|
||||
if (!block.start_date || !block.target_date) return;
|
||||
|
||||
const originalStartDate = new Date(block.start_date);
|
||||
const updatedStartDate = new Date(originalStartDate);
|
||||
|
||||
const originalTargetDate = new Date(block.target_date);
|
||||
const updatedTargetDate = new Date(originalTargetDate);
|
||||
|
||||
// update the start date on left resize
|
||||
if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts);
|
||||
// update the target date on right resize
|
||||
else if (dragDirection === "right") updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
|
||||
// update both the dates on x-axis move
|
||||
else if (dragDirection === "move") {
|
||||
updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts);
|
||||
updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
|
||||
}
|
||||
|
||||
// call the block update handler with the updated dates
|
||||
blockUpdateHandler(block.data, {
|
||||
start_date: renderFormattedPayloadDate(updatedStartDate) ?? undefined,
|
||||
target_date: renderFormattedPayloadDate(updatedTargetDate) ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`block-${block.id}`}
|
||||
className="relative min-w-full w-max"
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("relative h-full", {
|
||||
"bg-custom-background-80": isBlockActive(block.id),
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
|
||||
peekIssue?.issueId === block.data.id,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlockId(block.id)}
|
||||
onMouseLeave={() => updateActiveBlockId(null)}
|
||||
>
|
||||
{isBlockVisibleOnChart ? (
|
||||
<ChartDraggable
|
||||
block={block}
|
||||
blockToRender={blockToRender}
|
||||
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
ganttContainerRef={ganttContainerRef}
|
||||
/>
|
||||
) : (
|
||||
enableAddBlock && <ChartAddBlock block={block} blockUpdateHandler={blockUpdateHandler} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,16 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { FC } from "react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
import { useChart } from "../hooks";
|
||||
// helpers
|
||||
import { ChartAddBlock, ChartDraggable } from "components/gantt-chart";
|
||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
// components
|
||||
import { GanttChartBlock } from "./block";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT, HEADER_HEIGHT } from "../constants";
|
||||
import { HEADER_HEIGHT } from "../constants";
|
||||
|
||||
export type GanttChartBlocksProps = {
|
||||
itemsContainerWidth: number;
|
||||
@ -21,10 +15,11 @@ export type GanttChartBlocksProps = {
|
||||
enableBlockRightResize: boolean;
|
||||
enableBlockMove: boolean;
|
||||
enableAddBlock: boolean;
|
||||
ganttContainerRef: React.RefObject<HTMLDivElement>;
|
||||
showAllBlocks: boolean;
|
||||
};
|
||||
|
||||
export const GanttChartBlocksList: FC<GanttChartBlocksProps> = observer((props) => {
|
||||
export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
|
||||
const {
|
||||
itemsContainerWidth,
|
||||
blocks,
|
||||
@ -34,52 +29,9 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = observer((props)
|
||||
enableBlockRightResize,
|
||||
enableBlockMove,
|
||||
enableAddBlock,
|
||||
ganttContainerRef,
|
||||
showAllBlocks,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { peekIssue } = useIssueDetail();
|
||||
// chart hook
|
||||
const { activeBlock, dispatch } = useChart();
|
||||
|
||||
// update the active block on hover
|
||||
const updateActiveBlock = (block: IGanttBlock | null) => {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
activeBlock: block,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleChartBlockPosition = (
|
||||
block: IGanttBlock,
|
||||
totalBlockShifts: number,
|
||||
dragDirection: "left" | "right" | "move"
|
||||
) => {
|
||||
if (!block.start_date || !block.target_date) return;
|
||||
|
||||
const originalStartDate = new Date(block.start_date);
|
||||
const updatedStartDate = new Date(originalStartDate);
|
||||
|
||||
const originalTargetDate = new Date(block.target_date);
|
||||
const updatedTargetDate = new Date(originalTargetDate);
|
||||
|
||||
// update the start date on left resize
|
||||
if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts);
|
||||
// update the target date on right resize
|
||||
else if (dragDirection === "right") updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
|
||||
// update both the dates on x-axis move
|
||||
else if (dragDirection === "move") {
|
||||
updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts);
|
||||
updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts);
|
||||
}
|
||||
|
||||
// call the block update handler with the updated dates
|
||||
blockUpdateHandler(block.data, {
|
||||
start_date: renderFormattedPayloadDate(updatedStartDate) ?? undefined,
|
||||
target_date: renderFormattedPayloadDate(updatedTargetDate) ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -93,41 +45,19 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = observer((props)
|
||||
// hide the block if it doesn't have start and target dates and showAllBlocks is false
|
||||
if (!showAllBlocks && !(block.start_date && block.target_date)) return;
|
||||
|
||||
const isBlockVisibleOnChart = block.start_date && block.target_date;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`block-${block.id}`}
|
||||
className="relative min-w-full w-max"
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("relative h-full", {
|
||||
"bg-custom-background-80": activeBlock?.id === block.id,
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
|
||||
peekIssue?.issueId === block.data.id,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlock(block)}
|
||||
onMouseLeave={() => updateActiveBlock(null)}
|
||||
>
|
||||
{isBlockVisibleOnChart ? (
|
||||
<ChartDraggable
|
||||
block={block}
|
||||
blockToRender={blockToRender}
|
||||
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
/>
|
||||
) : (
|
||||
enableAddBlock && <ChartAddBlock block={block} blockUpdateHandler={blockUpdateHandler} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GanttChartBlock
|
||||
block={block}
|
||||
blockToRender={blockToRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableAddBlock={enableAddBlock}
|
||||
ganttContainerRef={ganttContainerRef}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
@ -1,10 +1,13 @@
|
||||
import { Expand, Shrink } from "lucide-react";
|
||||
// hooks
|
||||
import { useChart } from "../hooks";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { IGanttBlock, TGanttViews } from "../types";
|
||||
// constants
|
||||
import { VIEWS_LIST } from "components/gantt-chart/data";
|
||||
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
type Props = {
|
||||
blocks: IGanttBlock[] | null;
|
||||
@ -16,10 +19,10 @@ type Props = {
|
||||
toggleFullScreenMode: () => void;
|
||||
};
|
||||
|
||||
export const GanttChartHeader: React.FC<Props> = (props) => {
|
||||
export const GanttChartHeader: React.FC<Props> = observer((props) => {
|
||||
const { blocks, fullScreenMode, handleChartView, handleToday, loaderTitle, title, toggleFullScreenMode } = props;
|
||||
// chart hook
|
||||
const { currentView, allViews } = useChart();
|
||||
const { currentView } = useGanttChart();
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2 z-10">
|
||||
@ -29,7 +32,7 @@ export const GanttChartHeader: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{allViews?.map((chartView: any) => (
|
||||
{VIEWS_LIST.map((chartView: any) => (
|
||||
<div
|
||||
key={chartView?.key}
|
||||
className={cn("cursor-pointer rounded-sm p-1 px-2 text-xs", {
|
||||
@ -56,4 +59,4 @@ export const GanttChartHeader: React.FC<Props> = (props) => {
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,3 +1,7 @@
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||
// components
|
||||
import {
|
||||
BiWeekChartView,
|
||||
@ -12,7 +16,6 @@ import {
|
||||
TGanttViews,
|
||||
WeekChartView,
|
||||
YearChartView,
|
||||
useChart,
|
||||
} from "components/gantt-chart";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
@ -36,7 +39,7 @@ type Props = {
|
||||
quickAdd?: React.JSX.Element | undefined;
|
||||
};
|
||||
|
||||
export const GanttChartMainContent: React.FC<Props> = (props) => {
|
||||
export const GanttChartMainContent: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
blocks,
|
||||
blockToRender,
|
||||
@ -55,13 +58,15 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
|
||||
updateCurrentViewRenderPayload,
|
||||
quickAdd,
|
||||
} = props;
|
||||
// refs
|
||||
const ganttContainerRef = useRef<HTMLDivElement>(null);
|
||||
// chart hook
|
||||
const { currentView, currentViewData, updateScrollLeft } = useChart();
|
||||
const { currentView, currentViewData } = useGanttChart();
|
||||
// handling scroll functionality
|
||||
const onScroll = (e: React.UIEvent<HTMLDivElement, UIEvent>) => {
|
||||
const { clientWidth, scrollLeft, scrollWidth } = e.currentTarget;
|
||||
|
||||
updateScrollLeft(scrollLeft);
|
||||
// updateScrollLeft(scrollLeft);
|
||||
|
||||
const approxRangeLeft = scrollLeft >= clientWidth + 1000 ? 1000 : scrollLeft - clientWidth;
|
||||
const approxRangeRight = scrollWidth - (scrollLeft + clientWidth);
|
||||
@ -95,6 +100,7 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
|
||||
"mb-8": bottomSpacing,
|
||||
}
|
||||
)}
|
||||
ref={ganttContainerRef}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<GanttChartSidebar
|
||||
@ -105,7 +111,7 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
|
||||
title={title}
|
||||
quickAdd={quickAdd}
|
||||
/>
|
||||
<div className="relative min-h-full h-max flex-shrink-0 flex-grow">
|
||||
<div className="relative h-full flex-shrink-0 flex-grow">
|
||||
<ActiveChartView />
|
||||
{currentViewData && (
|
||||
<GanttChartBlocksList
|
||||
@ -117,10 +123,11 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableAddBlock={enableAddBlock}
|
||||
ganttContainerRef={ganttContainerRef}
|
||||
showAllBlocks={showAllBlocks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||
// components
|
||||
import { GanttChartHeader, useChart, GanttChartMainContent } from "components/gantt-chart";
|
||||
import { GanttChartHeader, GanttChartMainContent } from "components/gantt-chart";
|
||||
// views
|
||||
import {
|
||||
generateMonthChart,
|
||||
@ -34,7 +37,7 @@ type ChartViewRootProps = {
|
||||
quickAdd?: React.JSX.Element | undefined;
|
||||
};
|
||||
|
||||
export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
|
||||
export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
|
||||
const {
|
||||
border,
|
||||
title,
|
||||
@ -57,7 +60,8 @@ export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
|
||||
const [fullScreenMode, setFullScreenMode] = useState(false);
|
||||
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null);
|
||||
// hooks
|
||||
const { currentView, currentViewData, renderView, dispatch } = useChart();
|
||||
const { currentView, currentViewData, renderView, updateCurrentView, updateCurrentViewData, updateRenderView } =
|
||||
useGanttChart();
|
||||
|
||||
// rendering the block structure
|
||||
const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) =>
|
||||
@ -87,36 +91,20 @@ export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
|
||||
|
||||
// updating the prevData, currentData and nextData
|
||||
if (currentRender.payload.length > 0) {
|
||||
updateCurrentViewData(currentRender.state);
|
||||
|
||||
if (side === "left") {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
currentView: selectedCurrentView,
|
||||
currentViewData: currentRender.state,
|
||||
renderView: [...currentRender.payload, ...renderView],
|
||||
},
|
||||
});
|
||||
updateCurrentView(selectedCurrentView);
|
||||
updateRenderView([...currentRender.payload, ...renderView]);
|
||||
updatingCurrentLeftScrollPosition(currentRender.scrollWidth);
|
||||
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
|
||||
} else if (side === "right") {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
currentView: view,
|
||||
currentViewData: currentRender.state,
|
||||
renderView: [...renderView, ...currentRender.payload],
|
||||
},
|
||||
});
|
||||
updateCurrentView(view);
|
||||
updateRenderView([...renderView, ...currentRender.payload]);
|
||||
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
|
||||
} else {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
currentView: view,
|
||||
currentViewData: currentRender.state,
|
||||
renderView: [...currentRender.payload],
|
||||
},
|
||||
});
|
||||
updateCurrentView(view);
|
||||
updateRenderView(currentRender.payload);
|
||||
setItemsContainerWidth(currentRender.scrollWidth);
|
||||
setTimeout(() => {
|
||||
handleScrollToCurrentSelectedDate(currentRender.state, currentRender.state.data.currentDate);
|
||||
@ -206,4 +194,4 @@ export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { FC } from "react";
|
||||
// context
|
||||
import { useChart } from "components/gantt-chart";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
|
||||
|
||||
export const BiWeekChartView: FC<any> = () => {
|
||||
export const BiWeekChartView: FC<any> = observer(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
||||
const { currentView, currentViewData, renderView } = useGanttChart();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -50,4 +51,4 @@ export const BiWeekChartView: FC<any> = () => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { FC } from "react";
|
||||
// context
|
||||
import { useChart } from "../../hooks";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
|
||||
|
||||
export const DayChartView: FC<any> = () => {
|
||||
export const DayChartView: FC<any> = observer(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
||||
const { currentView, currentViewData, renderView } = useGanttChart();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -50,4 +51,4 @@ export const DayChartView: FC<any> = () => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { FC } from "react";
|
||||
// context
|
||||
import { useChart } from "components/gantt-chart";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
|
||||
|
||||
export const HourChartView: FC<any> = () => {
|
||||
export const HourChartView: FC<any> = observer(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
||||
const { currentView, currentViewData, renderView } = useGanttChart();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -50,4 +51,4 @@ export const HourChartView: FC<any> = () => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useChart } from "components/gantt-chart";
|
||||
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
@ -8,9 +9,9 @@ import { IMonthBlock } from "../../views";
|
||||
// constants
|
||||
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "components/gantt-chart/constants";
|
||||
|
||||
export const MonthChartView: FC<any> = () => {
|
||||
export const MonthChartView: FC<any> = observer(() => {
|
||||
// chart hook
|
||||
const { currentViewData, renderView } = useChart();
|
||||
const { currentViewData, renderView } = useGanttChart();
|
||||
const monthBlocks: IMonthBlock[] = renderView;
|
||||
|
||||
return (
|
||||
@ -71,4 +72,4 @@ export const MonthChartView: FC<any> = () => {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { FC } from "react";
|
||||
// context
|
||||
import { useChart } from "../../hooks";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
|
||||
|
||||
export const QuarterChartView: FC<any> = () => {
|
||||
export const QuarterChartView: FC<any> = observer(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
||||
const { currentView, currentViewData, renderView } = useGanttChart();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -46,4 +47,4 @@ export const QuarterChartView: FC<any> = () => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { FC } from "react";
|
||||
// context
|
||||
import { useChart } from "../../hooks";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
|
||||
|
||||
export const WeekChartView: FC<any> = () => {
|
||||
export const WeekChartView: FC<any> = observer(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
||||
const { currentView, currentViewData, renderView } = useGanttChart();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -50,4 +51,4 @@ export const WeekChartView: FC<any> = () => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { FC } from "react";
|
||||
// context
|
||||
import { useChart } from "../../hooks";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
|
||||
|
||||
export const YearChartView: FC<any> = () => {
|
||||
export const YearChartView: FC<any> = observer(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
||||
const { currentView, currentViewData, renderView } = useGanttChart();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -46,4 +47,4 @@ export const YearChartView: FC<any> = () => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,57 +1,19 @@
|
||||
import React, { createContext, useState } from "react";
|
||||
// types
|
||||
import { ChartContextData, ChartContextActionPayload, ChartContextReducer } from "../types";
|
||||
// data
|
||||
import { allViewsWithData, currentViewDataWithView } from "../data";
|
||||
import { createContext } from "react";
|
||||
// mobx store
|
||||
import { GanttStore } from "store/issue/issue_gantt_view.store";
|
||||
|
||||
export const ChartContext = createContext<ChartContextReducer | undefined>(undefined);
|
||||
let ganttViewStore = new GanttStore();
|
||||
|
||||
const chartReducer = (state: ChartContextData, action: ChartContextActionPayload): ChartContextData => {
|
||||
switch (action.type) {
|
||||
case "CURRENT_VIEW":
|
||||
return { ...state, currentView: action.payload };
|
||||
case "CURRENT_VIEW_DATA":
|
||||
return { ...state, currentViewData: action.payload };
|
||||
case "RENDER_VIEW":
|
||||
return { ...state, currentViewData: action.payload };
|
||||
case "PARTIAL_UPDATE":
|
||||
return { ...state, ...action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
export const GanttStoreContext = createContext<GanttStore>(ganttViewStore);
|
||||
|
||||
const initializeStore = () => {
|
||||
const _ganttStore = ganttViewStore ?? new GanttStore();
|
||||
if (typeof window === "undefined") return _ganttStore;
|
||||
if (!ganttViewStore) ganttViewStore = _ganttStore;
|
||||
return _ganttStore;
|
||||
};
|
||||
|
||||
const initialView = "month";
|
||||
|
||||
export const ChartContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// states;
|
||||
const [state, dispatch] = useState<ChartContextData>({
|
||||
currentView: initialView,
|
||||
currentViewData: currentViewDataWithView(initialView),
|
||||
renderView: [],
|
||||
allViews: allViewsWithData,
|
||||
activeBlock: null,
|
||||
});
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
const handleDispatch = (action: ChartContextActionPayload): ChartContextData => {
|
||||
const newState = chartReducer(state, action);
|
||||
dispatch(() => newState);
|
||||
return newState;
|
||||
};
|
||||
|
||||
const updateScrollLeft = (scrollLeft: number) => setScrollLeft(scrollLeft);
|
||||
|
||||
return (
|
||||
<ChartContext.Provider
|
||||
value={{
|
||||
...state,
|
||||
scrollLeft,
|
||||
updateScrollLeft,
|
||||
dispatch: handleDispatch,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
export const GanttStoreProvider = ({ children }: any) => {
|
||||
const store = initializeStore();
|
||||
return <GanttStoreContext.Provider value={store}>{children}</GanttStoreContext.Provider>;
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
// types
|
||||
import { WeekMonthDataType, ChartDataType } from "../types";
|
||||
import { WeekMonthDataType, ChartDataType, TGanttViews } from "../types";
|
||||
|
||||
// constants
|
||||
export const weeks: WeekMonthDataType[] = [
|
||||
@ -53,7 +53,7 @@ export const datePreview = (date: Date, includeTime: boolean = false) => {
|
||||
};
|
||||
|
||||
// context data
|
||||
export const allViewsWithData: ChartDataType[] = [
|
||||
export const VIEWS_LIST: ChartDataType[] = [
|
||||
// {
|
||||
// key: "hours",
|
||||
// title: "Hours",
|
||||
@ -133,7 +133,5 @@ export const allViewsWithData: ChartDataType[] = [
|
||||
// },
|
||||
];
|
||||
|
||||
export const currentViewDataWithView = (view: string = "month") => {
|
||||
const currentView: ChartDataType | undefined = allViewsWithData.find((_viewData) => _viewData.key === view);
|
||||
return currentView;
|
||||
};
|
||||
export const currentViewDataWithView = (view: TGanttViews = "month") =>
|
||||
VIEWS_LIST.find((_viewData) => _viewData.key === view);
|
||||
|
@ -1,21 +1,21 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { addDays } from "date-fns";
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useChart } from "../hooks";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
type Props = {
|
||||
block: IGanttBlock;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
};
|
||||
|
||||
export const ChartAddBlock: React.FC<Props> = (props) => {
|
||||
export const ChartAddBlock: React.FC<Props> = observer((props) => {
|
||||
const { block, blockUpdateHandler } = props;
|
||||
// states
|
||||
const [isButtonVisible, setIsButtonVisible] = useState(false);
|
||||
@ -24,7 +24,7 @@ export const ChartAddBlock: React.FC<Props> = (props) => {
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// chart hook
|
||||
const { currentViewData } = useChart();
|
||||
const { currentViewData } = useGanttChart();
|
||||
|
||||
const handleButtonClick = () => {
|
||||
if (!currentViewData) return;
|
||||
@ -88,4 +88,4 @@ export const ChartAddBlock: React.FC<Props> = (props) => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,12 +0,0 @@
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { IGanttBlock } from "components/gantt-chart";
|
||||
|
||||
export const renderIssueBlocksStructure = (blocks: TIssue[]): IGanttBlock[] =>
|
||||
blocks?.map((block) => ({
|
||||
data: block,
|
||||
id: block.id,
|
||||
sort_order: block.sort_order,
|
||||
start_date: block.start_date ? new Date(block.start_date) : null,
|
||||
target_date: block.target_date ? new Date(block.target_date) : null,
|
||||
}));
|
@ -1,11 +1,13 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
// hooks
|
||||
import { IGanttBlock, useChart } from "components/gantt-chart";
|
||||
import { IGanttBlock } from "components/gantt-chart";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// constants
|
||||
import { SIDEBAR_WIDTH } from "../constants";
|
||||
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
type Props = {
|
||||
block: IGanttBlock;
|
||||
@ -14,19 +16,29 @@ type Props = {
|
||||
enableBlockLeftResize: boolean;
|
||||
enableBlockRightResize: boolean;
|
||||
enableBlockMove: boolean;
|
||||
ganttContainerRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export const ChartDraggable: React.FC<Props> = (props) => {
|
||||
const { block, blockToRender, handleBlock, enableBlockLeftResize, enableBlockRightResize, enableBlockMove } = props;
|
||||
export const ChartDraggable: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
block,
|
||||
blockToRender,
|
||||
handleBlock,
|
||||
enableBlockLeftResize,
|
||||
enableBlockRightResize,
|
||||
enableBlockMove,
|
||||
ganttContainerRef,
|
||||
} = props;
|
||||
// states
|
||||
const [isLeftResizing, setIsLeftResizing] = useState(false);
|
||||
const [isRightResizing, setIsRightResizing] = useState(false);
|
||||
const [isMoving, setIsMoving] = useState(false);
|
||||
const [isHidden, setIsHidden] = useState(true);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
// refs
|
||||
const resizableRef = useRef<HTMLDivElement>(null);
|
||||
// chart hook
|
||||
const { currentViewData, scrollLeft } = useChart();
|
||||
const { currentViewData } = useGanttChart();
|
||||
// check if cursor reaches either end while resizing/dragging
|
||||
const checkScrollEnd = (e: MouseEvent): number => {
|
||||
const SCROLL_THRESHOLD = 70;
|
||||
@ -212,6 +224,17 @@ export const ChartDraggable: React.FC<Props> = (props) => {
|
||||
block.position?.width &&
|
||||
scrollLeft > block.position.marginLeft + block.position.width;
|
||||
|
||||
useEffect(() => {
|
||||
const ganttContainer = ganttContainerRef.current;
|
||||
if (!ganttContainer) return;
|
||||
|
||||
const handleScroll = () => setScrollLeft(ganttContainer.scrollLeft);
|
||||
ganttContainer.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
ganttContainer.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [ganttContainerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement;
|
||||
const resizableBlock = resizableRef.current;
|
||||
@ -234,7 +257,7 @@ export const ChartDraggable: React.FC<Props> = (props) => {
|
||||
return () => {
|
||||
observer.unobserve(resizableBlock);
|
||||
};
|
||||
}, [block.data.name]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -312,4 +335,4 @@ export const ChartDraggable: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,3 +1,2 @@
|
||||
export * from "./add-block";
|
||||
export * from "./block-structure";
|
||||
export * from "./draggable";
|
||||
|
1
web/components/gantt-chart/hooks/index.ts
Normal file
1
web/components/gantt-chart/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./use-gantt-chart";
|
@ -1,13 +0,0 @@
|
||||
import { useContext } from "react";
|
||||
// types
|
||||
import { ChartContextReducer } from "../types";
|
||||
// context
|
||||
import { ChartContext } from "../contexts";
|
||||
|
||||
export const useChart = (): ChartContextReducer => {
|
||||
const context = useContext(ChartContext);
|
||||
|
||||
if (!context) throw new Error("useChart must be used within a GanttChart");
|
||||
|
||||
return context;
|
||||
};
|
11
web/components/gantt-chart/hooks/use-gantt-chart.ts
Normal file
11
web/components/gantt-chart/hooks/use-gantt-chart.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { useContext } from "react";
|
||||
// mobx store
|
||||
import { GanttStoreContext } from "components/gantt-chart/contexts";
|
||||
// types
|
||||
import { IGanttStore } from "store/issue/issue_gantt_view.store";
|
||||
|
||||
export const useGanttChart = (): IGanttStore => {
|
||||
const context = useContext(GanttStoreContext);
|
||||
if (context === undefined) throw new Error("useGanttChart must be used within GanttStoreProvider");
|
||||
return context;
|
||||
};
|
@ -3,5 +3,5 @@ export * from "./chart";
|
||||
export * from "./helpers";
|
||||
export * from "./hooks";
|
||||
export * from "./root";
|
||||
export * from "./types";
|
||||
export * from "./sidebar";
|
||||
export * from "./types";
|
||||
|
@ -2,7 +2,7 @@ import { FC } from "react";
|
||||
// components
|
||||
import { ChartViewRoot, IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
|
||||
// context
|
||||
import { ChartContextProvider } from "./contexts";
|
||||
import { GanttStoreProvider } from "components/gantt-chart/contexts";
|
||||
|
||||
type GanttChartRootProps = {
|
||||
border?: boolean;
|
||||
@ -42,7 +42,7 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<ChartContextProvider>
|
||||
<GanttStoreProvider>
|
||||
<ChartViewRoot
|
||||
border={border}
|
||||
title={title}
|
||||
@ -60,6 +60,6 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
|
||||
showAllBlocks={showAllBlocks}
|
||||
quickAdd={quickAdd}
|
||||
/>
|
||||
</ChartContextProvider>
|
||||
</GanttStoreProvider>
|
||||
);
|
||||
};
|
||||
|
@ -1,158 +0,0 @@
|
||||
import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
// hooks
|
||||
import { useChart } from "components/gantt-chart/hooks";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { CycleGanttSidebarBlock } from "components/cycles";
|
||||
// helpers
|
||||
import { findTotalDaysInRange } from "helpers/date-time.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT } from "../constants";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
enableReorder: boolean;
|
||||
};
|
||||
|
||||
export const CycleGanttSidebar: React.FC<Props> = (props) => {
|
||||
const { blockUpdateHandler, blocks, enableReorder } = props;
|
||||
// chart hook
|
||||
const { activeBlock, dispatch } = useChart();
|
||||
|
||||
// update the active block on hover
|
||||
const updateActiveBlock = (block: IGanttBlock | null) => {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
activeBlock: block,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOrderChange = (result: DropResult) => {
|
||||
if (!blocks) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
// return if dropped outside the list
|
||||
if (!destination) return;
|
||||
|
||||
// return if dropped on the same index
|
||||
if (source.index === destination.index) return;
|
||||
|
||||
let updatedSortOrder = blocks[source.index].sort_order;
|
||||
|
||||
// update the sort order to the lowest if dropped at the top
|
||||
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
|
||||
// update the sort order to the highest if dropped at the bottom
|
||||
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
|
||||
// update the sort order to the average of the two adjacent blocks if dropped in between
|
||||
else {
|
||||
const destinationSortingOrder = blocks[destination.index].sort_order;
|
||||
const relativeDestinationSortingOrder =
|
||||
source.index < destination.index
|
||||
? blocks[destination.index + 1].sort_order
|
||||
: blocks[destination.index - 1].sort_order;
|
||||
|
||||
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
|
||||
}
|
||||
|
||||
// extract the element from the source index and insert it at the destination index without updating the entire array
|
||||
const removedElement = blocks.splice(source.index, 1)[0];
|
||||
blocks.splice(destination.index, 0, removedElement);
|
||||
|
||||
// call the block update handler with the updated sort order, new and old index
|
||||
blockUpdateHandler(removedElement.data, {
|
||||
sort_order: {
|
||||
destinationIndex: destination.index,
|
||||
newSortOrder: updatedSortOrder,
|
||||
sourceIndex: source.index,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleOrderChange}>
|
||||
<Droppable droppableId="gantt-sidebar">
|
||||
{(droppableProvided) => (
|
||||
<div className="h-full" ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
|
||||
<>
|
||||
{blocks ? (
|
||||
blocks.map((block, index) => {
|
||||
const duration = findTotalDaysInRange(block.start_date, block.target_date);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={cn({
|
||||
"rounded bg-custom-background-80": snapshot.isDragging,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlock(block)}
|
||||
onMouseLeave={() => updateActiveBlock(null)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<div
|
||||
id={`sidebar-block-${block.id}`}
|
||||
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
|
||||
"bg-custom-background-80": activeBlock?.id === block.id,
|
||||
})}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{enableReorder && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
|
||||
<div className="flex-grow truncate">
|
||||
<CycleGanttSidebarBlock cycleId={block.data.id} />
|
||||
</div>
|
||||
{duration && (
|
||||
<div className="flex-shrink-0 text-sm text-custom-text-200">
|
||||
{duration} day{duration > 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Loader className="space-y-3 pr-2">
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
</Loader>
|
||||
)}
|
||||
{droppableProvided.placeholder}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
72
web/components/gantt-chart/sidebar/cycles/block.tsx
Normal file
72
web/components/gantt-chart/sidebar/cycles/block.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
// hooks
|
||||
import { useGanttChart } from "components/gantt-chart/hooks";
|
||||
// components
|
||||
import { CycleGanttSidebarBlock } from "components/cycles";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { findTotalDaysInRange } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IGanttBlock } from "components/gantt-chart/types";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT } from "components/gantt-chart/constants";
|
||||
|
||||
type Props = {
|
||||
block: IGanttBlock;
|
||||
enableReorder: boolean;
|
||||
provided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
};
|
||||
|
||||
export const CyclesSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
const { block, enableReorder, provided, snapshot } = props;
|
||||
// store hooks
|
||||
const { updateActiveBlockId, isBlockActive } = useGanttChart();
|
||||
|
||||
const duration = findTotalDaysInRange(block.start_date, block.target_date);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn({
|
||||
"rounded bg-custom-background-80": snapshot.isDragging,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlockId(block.id)}
|
||||
onMouseLeave={() => updateActiveBlockId(null)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<div
|
||||
id={`sidebar-block-${block.id}`}
|
||||
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
|
||||
"bg-custom-background-80": isBlockActive(block.id),
|
||||
})}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{enableReorder && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
|
||||
<div className="flex-grow truncate">
|
||||
<CycleGanttSidebarBlock cycleId={block.data.id} />
|
||||
</div>
|
||||
{duration && (
|
||||
<div className="flex-shrink-0 text-sm text-custom-text-200">
|
||||
{duration} day{duration > 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
1
web/components/gantt-chart/sidebar/cycles/index.ts
Normal file
1
web/components/gantt-chart/sidebar/cycles/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./sidebar";
|
100
web/components/gantt-chart/sidebar/cycles/sidebar.tsx
Normal file
100
web/components/gantt-chart/sidebar/cycles/sidebar.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { CyclesSidebarBlock } from "./block";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
enableReorder: boolean;
|
||||
};
|
||||
|
||||
export const CycleGanttSidebar: React.FC<Props> = (props) => {
|
||||
const { blockUpdateHandler, blocks, enableReorder } = props;
|
||||
|
||||
const handleOrderChange = (result: DropResult) => {
|
||||
if (!blocks) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
// return if dropped outside the list
|
||||
if (!destination) return;
|
||||
|
||||
// return if dropped on the same index
|
||||
if (source.index === destination.index) return;
|
||||
|
||||
let updatedSortOrder = blocks[source.index].sort_order;
|
||||
|
||||
// update the sort order to the lowest if dropped at the top
|
||||
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
|
||||
// update the sort order to the highest if dropped at the bottom
|
||||
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
|
||||
// update the sort order to the average of the two adjacent blocks if dropped in between
|
||||
else {
|
||||
const destinationSortingOrder = blocks[destination.index].sort_order;
|
||||
const relativeDestinationSortingOrder =
|
||||
source.index < destination.index
|
||||
? blocks[destination.index + 1].sort_order
|
||||
: blocks[destination.index - 1].sort_order;
|
||||
|
||||
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
|
||||
}
|
||||
|
||||
// extract the element from the source index and insert it at the destination index without updating the entire array
|
||||
const removedElement = blocks.splice(source.index, 1)[0];
|
||||
blocks.splice(destination.index, 0, removedElement);
|
||||
|
||||
// call the block update handler with the updated sort order, new and old index
|
||||
blockUpdateHandler(removedElement.data, {
|
||||
sort_order: {
|
||||
destinationIndex: destination.index,
|
||||
newSortOrder: updatedSortOrder,
|
||||
sourceIndex: source.index,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleOrderChange}>
|
||||
<Droppable droppableId="gantt-sidebar">
|
||||
{(droppableProvided) => (
|
||||
<div className="h-full" ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
|
||||
<>
|
||||
{blocks ? (
|
||||
blocks.map((block, index) => (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<CyclesSidebarBlock
|
||||
block={block}
|
||||
enableReorder={enableReorder}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))
|
||||
) : (
|
||||
<Loader className="space-y-3 pr-2">
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
</Loader>
|
||||
)}
|
||||
{droppableProvided.placeholder}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
@ -1,173 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
// hooks
|
||||
import { useChart } from "components/gantt-chart/hooks";
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { IssueGanttSidebarBlock } from "components/issues";
|
||||
// helpers
|
||||
import { findTotalDaysInRange } from "helpers/date-time.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types";
|
||||
import { BLOCK_HEIGHT } from "../constants";
|
||||
|
||||
type Props = {
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
enableReorder: boolean;
|
||||
showAllBlocks?: boolean;
|
||||
};
|
||||
|
||||
export const IssueGanttSidebar: React.FC<Props> = observer((props: Props) => {
|
||||
const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props;
|
||||
|
||||
const { activeBlock, dispatch } = useChart();
|
||||
const { peekIssue } = useIssueDetail();
|
||||
|
||||
// update the active block on hover
|
||||
const updateActiveBlock = (block: IGanttBlock | null) => {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
activeBlock: block,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOrderChange = (result: DropResult) => {
|
||||
if (!blocks) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
// return if dropped outside the list
|
||||
if (!destination) return;
|
||||
|
||||
// return if dropped on the same index
|
||||
if (source.index === destination.index) return;
|
||||
|
||||
let updatedSortOrder = blocks[source.index].sort_order;
|
||||
|
||||
// update the sort order to the lowest if dropped at the top
|
||||
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
|
||||
// update the sort order to the highest if dropped at the bottom
|
||||
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
|
||||
// update the sort order to the average of the two adjacent blocks if dropped in between
|
||||
else {
|
||||
const destinationSortingOrder = blocks[destination.index].sort_order;
|
||||
const relativeDestinationSortingOrder =
|
||||
source.index < destination.index
|
||||
? blocks[destination.index + 1].sort_order
|
||||
: blocks[destination.index - 1].sort_order;
|
||||
|
||||
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
|
||||
}
|
||||
|
||||
// extract the element from the source index and insert it at the destination index without updating the entire array
|
||||
const removedElement = blocks.splice(source.index, 1)[0];
|
||||
blocks.splice(destination.index, 0, removedElement);
|
||||
|
||||
// call the block update handler with the updated sort order, new and old index
|
||||
blockUpdateHandler(removedElement.data, {
|
||||
sort_order: {
|
||||
destinationIndex: destination.index,
|
||||
newSortOrder: updatedSortOrder,
|
||||
sourceIndex: source.index,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DragDropContext onDragEnd={handleOrderChange}>
|
||||
<Droppable droppableId="gantt-sidebar">
|
||||
{(droppableProvided) => (
|
||||
<div ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
|
||||
<>
|
||||
{blocks ? (
|
||||
blocks.map((block, index) => {
|
||||
const isBlockVisibleOnSidebar = block.start_date && block.target_date;
|
||||
|
||||
// hide the block if it doesn't have start and target dates and showAllBlocks is false
|
||||
if (!showAllBlocks && !isBlockVisibleOnSidebar) return;
|
||||
|
||||
const duration =
|
||||
!block.start_date || !block.target_date
|
||||
? null
|
||||
: findTotalDaysInRange(block.start_date, block.target_date);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={cn({
|
||||
"rounded bg-custom-background-80": snapshot.isDragging,
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
|
||||
peekIssue?.issueId === block.data.id,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlock(block)}
|
||||
onMouseLeave={() => updateActiveBlock(null)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<div
|
||||
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
|
||||
"bg-custom-background-80": activeBlock?.id === block.id,
|
||||
})}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{enableReorder && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
|
||||
<div className="flex-grow truncate">
|
||||
<IssueGanttSidebarBlock issueId={block.data.id} />
|
||||
</div>
|
||||
{duration && (
|
||||
<div className="flex-shrink-0 text-sm text-custom-text-200">
|
||||
<span>
|
||||
{duration} day{duration > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Loader className="space-y-3 pr-2">
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
</Loader>
|
||||
)}
|
||||
{droppableProvided.placeholder}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</>
|
||||
);
|
||||
});
|
77
web/components/gantt-chart/sidebar/issues/block.tsx
Normal file
77
web/components/gantt-chart/sidebar/issues/block.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
import { useGanttChart } from "components/gantt-chart/hooks";
|
||||
// components
|
||||
import { IssueGanttSidebarBlock } from "components/issues";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { findTotalDaysInRange } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IGanttBlock } from "../../types";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT } from "../../constants";
|
||||
|
||||
type Props = {
|
||||
block: IGanttBlock;
|
||||
enableReorder: boolean;
|
||||
provided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
};
|
||||
|
||||
export const IssuesSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
const { block, enableReorder, provided, snapshot } = props;
|
||||
// store hooks
|
||||
const { updateActiveBlockId, isBlockActive } = useGanttChart();
|
||||
const { peekIssue } = useIssueDetail();
|
||||
|
||||
const duration = findTotalDaysInRange(block.start_date, block.target_date);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn({
|
||||
"rounded bg-custom-background-80": snapshot.isDragging,
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
|
||||
peekIssue?.issueId === block.data.id,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlockId(block.id)}
|
||||
onMouseLeave={() => updateActiveBlockId(null)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<div
|
||||
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
|
||||
"bg-custom-background-80": isBlockActive(block.id),
|
||||
})}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{enableReorder && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
|
||||
<div className="flex-grow truncate">
|
||||
<IssueGanttSidebarBlock issueId={block.data.id} />
|
||||
</div>
|
||||
{duration && (
|
||||
<div className="flex-shrink-0 text-sm text-custom-text-200">
|
||||
<span>
|
||||
{duration} day{duration > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
1
web/components/gantt-chart/sidebar/issues/index.ts
Normal file
1
web/components/gantt-chart/sidebar/issues/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./sidebar";
|
107
web/components/gantt-chart/sidebar/issues/sidebar.tsx
Normal file
107
web/components/gantt-chart/sidebar/issues/sidebar.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
|
||||
// components
|
||||
import { IssuesSidebarBlock } from "./block";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// types
|
||||
import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types";
|
||||
|
||||
type Props = {
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
enableReorder: boolean;
|
||||
showAllBlocks?: boolean;
|
||||
};
|
||||
|
||||
export const IssueGanttSidebar: React.FC<Props> = (props) => {
|
||||
const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props;
|
||||
|
||||
const handleOrderChange = (result: DropResult) => {
|
||||
if (!blocks) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
// return if dropped outside the list
|
||||
if (!destination) return;
|
||||
|
||||
// return if dropped on the same index
|
||||
if (source.index === destination.index) return;
|
||||
|
||||
let updatedSortOrder = blocks[source.index].sort_order;
|
||||
|
||||
// update the sort order to the lowest if dropped at the top
|
||||
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
|
||||
// update the sort order to the highest if dropped at the bottom
|
||||
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
|
||||
// update the sort order to the average of the two adjacent blocks if dropped in between
|
||||
else {
|
||||
const destinationSortingOrder = blocks[destination.index].sort_order;
|
||||
const relativeDestinationSortingOrder =
|
||||
source.index < destination.index
|
||||
? blocks[destination.index + 1].sort_order
|
||||
: blocks[destination.index - 1].sort_order;
|
||||
|
||||
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
|
||||
}
|
||||
|
||||
// extract the element from the source index and insert it at the destination index without updating the entire array
|
||||
const removedElement = blocks.splice(source.index, 1)[0];
|
||||
blocks.splice(destination.index, 0, removedElement);
|
||||
|
||||
// call the block update handler with the updated sort order, new and old index
|
||||
blockUpdateHandler(removedElement.data, {
|
||||
sort_order: {
|
||||
destinationIndex: destination.index,
|
||||
newSortOrder: updatedSortOrder,
|
||||
sourceIndex: source.index,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleOrderChange}>
|
||||
<Droppable droppableId="gantt-sidebar">
|
||||
{(droppableProvided) => (
|
||||
<div ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
|
||||
<>
|
||||
{blocks ? (
|
||||
blocks.map((block, index) => {
|
||||
const isBlockVisibleOnSidebar = block.start_date && block.target_date;
|
||||
|
||||
// hide the block if it doesn't have start and target dates and showAllBlocks is false
|
||||
if (!showAllBlocks && !isBlockVisibleOnSidebar) return;
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<IssuesSidebarBlock
|
||||
block={block}
|
||||
enableReorder={enableReorder}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Loader className="space-y-3 pr-2">
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
</Loader>
|
||||
)}
|
||||
{droppableProvided.placeholder}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
@ -1,158 +0,0 @@
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
// hooks
|
||||
import { useChart } from "components/gantt-chart/hooks";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { ModuleGanttSidebarBlock } from "components/modules";
|
||||
// helpers
|
||||
import { findTotalDaysInRange } from "helpers/date-time.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT } from "../constants";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
enableReorder: boolean;
|
||||
};
|
||||
|
||||
export const ModuleGanttSidebar: React.FC<Props> = (props) => {
|
||||
const { blockUpdateHandler, blocks, enableReorder } = props;
|
||||
// chart hook
|
||||
const { activeBlock, dispatch } = useChart();
|
||||
|
||||
// update the active block on hover
|
||||
const updateActiveBlock = (block: IGanttBlock | null) => {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
activeBlock: block,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOrderChange = (result: DropResult) => {
|
||||
if (!blocks) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
// return if dropped outside the list
|
||||
if (!destination) return;
|
||||
|
||||
// return if dropped on the same index
|
||||
if (source.index === destination.index) return;
|
||||
|
||||
let updatedSortOrder = blocks[source.index].sort_order;
|
||||
|
||||
// update the sort order to the lowest if dropped at the top
|
||||
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
|
||||
// update the sort order to the highest if dropped at the bottom
|
||||
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
|
||||
// update the sort order to the average of the two adjacent blocks if dropped in between
|
||||
else {
|
||||
const destinationSortingOrder = blocks[destination.index].sort_order;
|
||||
const relativeDestinationSortingOrder =
|
||||
source.index < destination.index
|
||||
? blocks[destination.index + 1].sort_order
|
||||
: blocks[destination.index - 1].sort_order;
|
||||
|
||||
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
|
||||
}
|
||||
|
||||
// extract the element from the source index and insert it at the destination index without updating the entire array
|
||||
const removedElement = blocks.splice(source.index, 1)[0];
|
||||
blocks.splice(destination.index, 0, removedElement);
|
||||
|
||||
// call the block update handler with the updated sort order, new and old index
|
||||
blockUpdateHandler(removedElement.data, {
|
||||
sort_order: {
|
||||
destinationIndex: destination.index,
|
||||
newSortOrder: updatedSortOrder,
|
||||
sourceIndex: source.index,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleOrderChange}>
|
||||
<Droppable droppableId="gantt-sidebar">
|
||||
{(droppableProvided) => (
|
||||
<div className="h-full" ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
|
||||
<>
|
||||
{blocks ? (
|
||||
blocks.map((block, index) => {
|
||||
const duration = findTotalDaysInRange(block.start_date, block.target_date);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={cn({
|
||||
"rounded bg-custom-background-80": snapshot.isDragging,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlock(block)}
|
||||
onMouseLeave={() => updateActiveBlock(null)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<div
|
||||
id={`sidebar-block-${block.id}`}
|
||||
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
|
||||
"bg-custom-background-80": activeBlock?.id === block.id,
|
||||
})}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{enableReorder && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
|
||||
<div className="flex-grow truncate">
|
||||
<ModuleGanttSidebarBlock moduleId={block.data.id} />
|
||||
</div>
|
||||
{duration !== undefined && (
|
||||
<div className="flex-shrink-0 text-sm text-custom-text-200">
|
||||
{duration} day{duration > 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Loader className="space-y-3 pr-2">
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
</Loader>
|
||||
)}
|
||||
{droppableProvided.placeholder}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
72
web/components/gantt-chart/sidebar/modules/block.tsx
Normal file
72
web/components/gantt-chart/sidebar/modules/block.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
// hooks
|
||||
import { useGanttChart } from "components/gantt-chart/hooks";
|
||||
// components
|
||||
import { ModuleGanttSidebarBlock } from "components/modules";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { findTotalDaysInRange } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IGanttBlock } from "components/gantt-chart/types";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT } from "components/gantt-chart/constants";
|
||||
|
||||
type Props = {
|
||||
block: IGanttBlock;
|
||||
enableReorder: boolean;
|
||||
provided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
};
|
||||
|
||||
export const ModulesSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
const { block, enableReorder, provided, snapshot } = props;
|
||||
// store hooks
|
||||
const { updateActiveBlockId, isBlockActive } = useGanttChart();
|
||||
|
||||
const duration = findTotalDaysInRange(block.start_date, block.target_date);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn({
|
||||
"rounded bg-custom-background-80": snapshot.isDragging,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlockId(block.id)}
|
||||
onMouseLeave={() => updateActiveBlockId(null)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<div
|
||||
id={`sidebar-block-${block.id}`}
|
||||
className={cn("group w-full flex items-center gap-2 pl-2 pr-4", {
|
||||
"bg-custom-background-80": isBlockActive(block.id),
|
||||
})}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{enableReorder && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
|
||||
<div className="flex-grow truncate">
|
||||
<ModuleGanttSidebarBlock moduleId={block.data.id} />
|
||||
</div>
|
||||
{duration !== undefined && (
|
||||
<div className="flex-shrink-0 text-sm text-custom-text-200">
|
||||
{duration} day{duration > 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
1
web/components/gantt-chart/sidebar/modules/index.ts
Normal file
1
web/components/gantt-chart/sidebar/modules/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./sidebar";
|
100
web/components/gantt-chart/sidebar/modules/sidebar.tsx
Normal file
100
web/components/gantt-chart/sidebar/modules/sidebar.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { ModulesSidebarBlock } from "./block";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
enableReorder: boolean;
|
||||
};
|
||||
|
||||
export const ModuleGanttSidebar: React.FC<Props> = (props) => {
|
||||
const { blockUpdateHandler, blocks, enableReorder } = props;
|
||||
|
||||
const handleOrderChange = (result: DropResult) => {
|
||||
if (!blocks) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
// return if dropped outside the list
|
||||
if (!destination) return;
|
||||
|
||||
// return if dropped on the same index
|
||||
if (source.index === destination.index) return;
|
||||
|
||||
let updatedSortOrder = blocks[source.index].sort_order;
|
||||
|
||||
// update the sort order to the lowest if dropped at the top
|
||||
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
|
||||
// update the sort order to the highest if dropped at the bottom
|
||||
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
|
||||
// update the sort order to the average of the two adjacent blocks if dropped in between
|
||||
else {
|
||||
const destinationSortingOrder = blocks[destination.index].sort_order;
|
||||
const relativeDestinationSortingOrder =
|
||||
source.index < destination.index
|
||||
? blocks[destination.index + 1].sort_order
|
||||
: blocks[destination.index - 1].sort_order;
|
||||
|
||||
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
|
||||
}
|
||||
|
||||
// extract the element from the source index and insert it at the destination index without updating the entire array
|
||||
const removedElement = blocks.splice(source.index, 1)[0];
|
||||
blocks.splice(destination.index, 0, removedElement);
|
||||
|
||||
// call the block update handler with the updated sort order, new and old index
|
||||
blockUpdateHandler(removedElement.data, {
|
||||
sort_order: {
|
||||
destinationIndex: destination.index,
|
||||
newSortOrder: updatedSortOrder,
|
||||
sourceIndex: source.index,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleOrderChange}>
|
||||
<Droppable droppableId="gantt-sidebar">
|
||||
{(droppableProvided) => (
|
||||
<div className="h-full" ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
|
||||
<>
|
||||
{blocks ? (
|
||||
blocks.map((block, index) => (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<ModulesSidebarBlock
|
||||
block={block}
|
||||
enableReorder={enableReorder}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))
|
||||
) : (
|
||||
<Loader className="space-y-3 pr-2">
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
<Loader.Item height="34px" />
|
||||
</Loader>
|
||||
)}
|
||||
{droppableProvided.placeholder}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
@ -1,17 +1,10 @@
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
// hooks
|
||||
import { useChart } from "components/gantt-chart/hooks";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { IssueGanttSidebarBlock } from "components/issues";
|
||||
// helpers
|
||||
import { findTotalDaysInRange } from "helpers/date-time.helper";
|
||||
import { IssuesSidebarBlock } from "./issues/block";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types";
|
||||
// constants
|
||||
import { BLOCK_HEIGHT } from "../constants";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
@ -23,18 +16,6 @@ type Props = {
|
||||
|
||||
export const ProjectViewGanttSidebar: React.FC<Props> = (props) => {
|
||||
const { blockUpdateHandler, blocks, enableReorder } = props;
|
||||
// chart hook
|
||||
const { activeBlock, dispatch } = useChart();
|
||||
|
||||
// update the active block on hover
|
||||
const updateActiveBlock = (block: IGanttBlock | null) => {
|
||||
dispatch({
|
||||
type: "PARTIAL_UPDATE",
|
||||
payload: {
|
||||
activeBlock: block,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOrderChange = (result: DropResult) => {
|
||||
if (!blocks) return;
|
||||
@ -89,59 +70,23 @@ export const ProjectViewGanttSidebar: React.FC<Props> = (props) => {
|
||||
>
|
||||
<>
|
||||
{blocks ? (
|
||||
blocks.map((block, index) => {
|
||||
const duration = findTotalDaysInRange(block.start_date, block.target_date);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className={`${snapshot.isDragging ? "rounded bg-custom-background-80" : ""}`}
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
}}
|
||||
onMouseEnter={() => updateActiveBlock(block)}
|
||||
onMouseLeave={() => updateActiveBlock(null)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<div
|
||||
id={`sidebar-block-${block.id}`}
|
||||
className={`group flex h-full w-full items-center gap-2 rounded-l px-2 pr-4 ${
|
||||
activeBlock?.id === block.id ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
>
|
||||
{enableReorder && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 rounded p-0.5 text-custom-sidebar-text-200 opacity-0 group-hover:opacity-100"
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<MoreVertical className="-ml-5 h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex h-full flex-grow items-center justify-between gap-2 truncate">
|
||||
<div className="flex-grow truncate">
|
||||
<IssueGanttSidebarBlock issueId={block.data.id} />
|
||||
</div>
|
||||
{duration !== undefined && (
|
||||
<div className="flex-shrink-0 text-sm text-custom-text-200">
|
||||
{duration} day{duration > 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})
|
||||
blocks.map((block, index) => (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<IssuesSidebarBlock
|
||||
block={block}
|
||||
enableReorder={enableReorder}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))
|
||||
) : (
|
||||
<Loader className="space-y-3 pr-2">
|
||||
<Loader.Item height="34px" />
|
||||
|
@ -1,10 +1,3 @@
|
||||
// context types
|
||||
export type allViewsType = {
|
||||
key: string;
|
||||
title: string;
|
||||
data: Object | null;
|
||||
};
|
||||
|
||||
export interface IGanttBlock {
|
||||
data: any;
|
||||
id: string;
|
||||
@ -29,34 +22,6 @@ export interface IBlockUpdateData {
|
||||
|
||||
export type TGanttViews = "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year";
|
||||
|
||||
export interface ChartContextData {
|
||||
allViews: allViewsType[];
|
||||
currentView: TGanttViews;
|
||||
currentViewData: ChartDataType | undefined;
|
||||
renderView: any;
|
||||
activeBlock: IGanttBlock | null;
|
||||
}
|
||||
|
||||
export type ChartContextActionPayload =
|
||||
| {
|
||||
type: "CURRENT_VIEW";
|
||||
payload: TGanttViews;
|
||||
}
|
||||
| {
|
||||
type: "CURRENT_VIEW_DATA" | "RENDER_VIEW";
|
||||
payload: ChartDataType | undefined;
|
||||
}
|
||||
| {
|
||||
type: "PARTIAL_UPDATE";
|
||||
payload: Partial<ChartContextData>;
|
||||
};
|
||||
|
||||
export interface ChartContextReducer extends ChartContextData {
|
||||
scrollLeft: number;
|
||||
updateScrollLeft: (scrollLeft: number) => void;
|
||||
dispatch: (action: ChartContextActionPayload) => void;
|
||||
}
|
||||
|
||||
// chart render types
|
||||
export interface WeekMonthDataType {
|
||||
key: number;
|
||||
|
@ -149,7 +149,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
cycleDetails={cycleDetails ?? undefined}
|
||||
/>
|
||||
<div className="relative z-10 w-full items-center gap-x-2 gap-y-4">
|
||||
<div className="relative z-[15] w-full items-center gap-x-2 gap-y-4">
|
||||
<div className="flex justify-between border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarHamburgerToggle />
|
||||
@ -175,7 +175,12 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<Link href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} className="block md:hidden pl-2 text-custom-text-300">...</Link>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
className="block md:hidden pl-2 text-custom-text-300"
|
||||
>
|
||||
...
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@ -282,5 +287,3 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
@ -107,7 +107,7 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => {
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateWorkspaceViewModal isOpen={createViewModal} onClose={() => setCreateViewModal(false)} />
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="relative z-[15] flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="relative flex gap-2">
|
||||
<SidebarHamburgerToggle />
|
||||
<Breadcrumbs>
|
||||
|
@ -152,7 +152,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
moduleDetails={moduleDetails ?? undefined}
|
||||
/>
|
||||
<div className="relative z-10 items-center gap-x-2 gap-y-4">
|
||||
<div className="relative z-[15] items-center gap-x-2 gap-y-4">
|
||||
<div className="flex justify-between border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarHamburgerToggle />
|
||||
@ -178,7 +178,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<Link href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} className="block md:hidden pl-2 text-custom-text-300">...</Link>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
className="block md:hidden pl-2 text-custom-text-300"
|
||||
>
|
||||
...
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@ -249,7 +254,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
|
||||
{canUserCreateIssue && (
|
||||
<>
|
||||
<Button className="hidden md:block" onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
|
||||
<Button
|
||||
className="hidden md:block"
|
||||
onClick={() => setAnalyticsModal(true)}
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
>
|
||||
Analytics
|
||||
</Button>
|
||||
<Button
|
||||
@ -270,8 +280,15 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<ArrowRight className={`h-4 w-4 duration-300 hidden md:block ${isSidebarCollapsed ? "-rotate-180" : ""}`} />
|
||||
<PanelRight className={cn("w-4 h-4 block md:hidden", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
|
||||
<ArrowRight
|
||||
className={`h-4 w-4 duration-300 hidden md:block ${isSidebarCollapsed ? "-rotate-180" : ""}`}
|
||||
/>
|
||||
<PanelRight
|
||||
className={cn(
|
||||
"w-4 h-4 block md:hidden",
|
||||
!isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -109,7 +109,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
projectDetails={currentProjectDetails ?? undefined}
|
||||
/>
|
||||
<div className=" relative z-10 items-center gap-x-2 gap-y-4">
|
||||
<div className="relative z-[15] items-center gap-x-2 gap-y-4">
|
||||
<div className="flex items-center gap-2 p-4 border-b border-custom-border-200 bg-custom-sidebar-background-100">
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<SidebarHamburgerToggle />
|
||||
|
@ -108,7 +108,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="relative z-[15] flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarHamburgerToggle />
|
||||
<Breadcrumbs>
|
||||
|
@ -196,9 +196,9 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = observer(
|
||||
const updateDraftIssue = async (payload: Partial<TIssue>) => {
|
||||
await draftIssues
|
||||
.updateIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload)
|
||||
.then((res) => {
|
||||
.then(() => {
|
||||
if (isUpdatingSingleIssue) {
|
||||
mutate<TIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
|
||||
mutate<TIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...payload } as TIssue), false);
|
||||
} else {
|
||||
if (payload.parent_id) mutate(SUB_ISSUES(payload.parent_id.toString()));
|
||||
}
|
||||
|
@ -53,7 +53,8 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
reset,
|
||||
} = useForm<Partial<TIssueComment>>({ defaultValues: { comment_html: "<p></p>" } });
|
||||
watch,
|
||||
} = useForm<Partial<TIssueComment>>({ defaultValues: { comment_html: "" } });
|
||||
|
||||
const onSubmit = async (formData: Partial<TIssueComment>) => {
|
||||
await activityOperations.createComment(formData).finally(() => {
|
||||
@ -88,7 +89,7 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
|
||||
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
|
||||
restoreFile={fileService.getRestoreImageFunction(workspaceId)}
|
||||
ref={editorRef}
|
||||
value={!value ? "<p></p>" : value}
|
||||
value={value ?? ""}
|
||||
customClassName="p-2"
|
||||
editorContentCustomClassNames="min-h-[35px]"
|
||||
debouncedUpdatesEnabled={false}
|
||||
@ -104,7 +105,7 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
|
||||
}
|
||||
submitButton={
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || watch("comment_html") === ""}
|
||||
variant="primary"
|
||||
type="submit"
|
||||
className="!px-2.5 !py-1.5 !text-xs"
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { FC, useState } from "react";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
import { useIssueDetail, useMember } from "hooks/store";
|
||||
// ui
|
||||
import { ExternalLinkIcon, Tooltip } from "@plane/ui";
|
||||
// icons
|
||||
@ -26,6 +26,7 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
|
||||
toggleIssueLinkModal: toggleIssueLinkModalStore,
|
||||
link: { getLinkById },
|
||||
} = useIssueDetail();
|
||||
const { getUserDetails } = useMember();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
// state
|
||||
@ -38,6 +39,8 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
|
||||
const linkDetail = getLinkById(linkId);
|
||||
if (!linkDetail) return <></>;
|
||||
|
||||
const createdByDetails = getUserDetails(linkDetail.created_by_id);
|
||||
|
||||
return (
|
||||
<div key={linkId}>
|
||||
<IssueLinkCreateUpdateModal
|
||||
@ -110,10 +113,11 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
|
||||
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
|
||||
Added {calculateTimeAgo(linkDetail.created_at)}
|
||||
<br />
|
||||
by{" "}
|
||||
{linkDetail.created_by_detail.is_bot
|
||||
? linkDetail.created_by_detail.first_name + " Bot"
|
||||
: linkDetail.created_by_detail.display_name}
|
||||
{createdByDetails && (
|
||||
<>
|
||||
by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -96,7 +96,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
showToast: boolean = true
|
||||
) => {
|
||||
try {
|
||||
const response = await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
if (showToast) {
|
||||
setToastAlert({
|
||||
title: "Issue updated successfully",
|
||||
@ -106,7 +106,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
}
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
|
||||
payload: { ...data, issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
change_details: Object.values(data).join(","),
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { differenceInCalendarDays } from "date-fns";
|
||||
import {
|
||||
LinkIcon,
|
||||
Signal,
|
||||
@ -15,7 +14,7 @@ import {
|
||||
CalendarDays,
|
||||
} from "lucide-react";
|
||||
// hooks
|
||||
import { useEstimate, useIssueDetail, useProject, useUser } from "hooks/store";
|
||||
import { useEstimate, useIssueDetail, useProject, useProjectState, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import {
|
||||
@ -41,6 +40,7 @@ import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, UserGroupIcon }
|
||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { shouldHighlightIssueDueDate } from "helpers/issue.helper";
|
||||
// types
|
||||
import type { TIssueOperations } from "./root";
|
||||
|
||||
@ -65,6 +65,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getStateById } = useProjectState();
|
||||
// states
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
|
||||
@ -83,6 +84,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
};
|
||||
|
||||
const projectDetails = issue ? getProjectById(issue.project_id) : null;
|
||||
const stateDetails = getStateById(issue.state_id);
|
||||
|
||||
const minDate = issue.start_date ? new Date(issue.start_date) : null;
|
||||
minDate?.setDate(minDate.getDate());
|
||||
@ -90,8 +92,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const maxDate = issue.target_date ? new Date(issue.target_date) : null;
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
{workspaceSlug && projectId && issue && (
|
||||
@ -242,7 +242,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={cn("text-sm", {
|
||||
"text-custom-text-400": !issue.target_date,
|
||||
"text-red-500": targetDateDistance <= 0,
|
||||
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
|
||||
})}
|
||||
hideIcon
|
||||
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { FC, useState } from "react";
|
||||
import { Bell, BellOff } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { FC, useState } from "react";
|
||||
// UI
|
||||
import { Button, Loader } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
import isNil from "lodash/isNil";
|
||||
|
||||
export type TIssueSubscription = {
|
||||
workspaceSlug: string;
|
||||
@ -25,17 +26,17 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
|
||||
// state
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const subscription = getSubscriptionByIssueId(issueId);
|
||||
const isSubscribed = getSubscriptionByIssueId(issueId);
|
||||
|
||||
const handleSubscription = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (subscription?.subscribed) await removeSubscription(workspaceSlug, projectId, issueId);
|
||||
if (isSubscribed) await removeSubscription(workspaceSlug, projectId, issueId);
|
||||
else await createSubscription(workspaceSlug, projectId, issueId);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`,
|
||||
message: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`,
|
||||
title: `Issue ${isSubscribed ? `unsubscribed` : `subscribed`} successfully.!`,
|
||||
message: `Issue ${isSubscribed ? `unsubscribed` : `subscribed`} successfully.!`,
|
||||
});
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
@ -48,42 +49,32 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!subscription)
|
||||
if (isNil(isSubscribed))
|
||||
return (
|
||||
<Loader>
|
||||
<Loader.Item width="92px" height="27px" />
|
||||
<Loader.Item width="106px" height="28px" />
|
||||
</Loader>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{subscription ? (
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
prependIcon={subscription?.subscribed ? <BellOff /> : <Bell className="h-3 w-3" />}
|
||||
variant="outline-primary"
|
||||
className="hover:!bg-custom-primary-100/20"
|
||||
onClick={handleSubscription}
|
||||
>
|
||||
{loading ? (
|
||||
<span>
|
||||
<span className="hidden sm:block">Loading...</span>
|
||||
</span>
|
||||
) : subscription?.subscribed ? (
|
||||
<div className="hidden sm:block">Unsubscribe</div>
|
||||
) : (
|
||||
<div className="hidden sm:block">Subscribe</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Loader>
|
||||
<Loader.Item height="28px" width="106px" />
|
||||
</Loader>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
prependIcon={isSubscribed ? <BellOff /> : <Bell className="h-3 w-3" />}
|
||||
variant="outline-primary"
|
||||
className="hover:!bg-custom-primary-100/20"
|
||||
onClick={handleSubscription}
|
||||
>
|
||||
{loading ? (
|
||||
<span>
|
||||
<span className="hidden sm:block">Loading</span>...
|
||||
</span>
|
||||
) : isSubscribed ? (
|
||||
<div className="hidden sm:block">Unsubscribe</div>
|
||||
) : (
|
||||
<div className="hidden sm:block">Subscribe</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -73,42 +73,44 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<CalendarHeader issuesFilterStore={issuesFilterStore} viewId={viewId} />
|
||||
<CalendarWeekHeader isLoading={!issues} showWeekends={showWeekends} />
|
||||
<div className="h-full w-full overflow-y-auto vertical-scrollbar scrollbar-lg">
|
||||
{layout === "month" && (
|
||||
<div className="grid h-full w-full grid-cols-1 divide-y-[0.5px] divide-custom-border-200">
|
||||
{allWeeksOfActiveMonth &&
|
||||
Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => (
|
||||
<CalendarWeekDays
|
||||
issuesFilterStore={issuesFilterStore}
|
||||
key={weekIndex}
|
||||
week={week}
|
||||
issues={issues}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
enableQuickIssueCreate
|
||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||
quickActions={quickActions}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{layout === "week" && (
|
||||
<CalendarWeekDays
|
||||
issuesFilterStore={issuesFilterStore}
|
||||
week={issueCalendarView.allDaysOfActiveWeek}
|
||||
issues={issues}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
enableQuickIssueCreate
|
||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||
quickActions={quickActions}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
<div className="flex h-full w-full vertical-scrollbar scrollbar-lg flex-col">
|
||||
<CalendarWeekHeader isLoading={!issues} showWeekends={showWeekends} />
|
||||
<div className="h-full w-full">
|
||||
{layout === "month" && (
|
||||
<div className="grid h-full w-full grid-cols-1 divide-y-[0.5px] divide-custom-border-200">
|
||||
{allWeeksOfActiveMonth &&
|
||||
Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => (
|
||||
<CalendarWeekDays
|
||||
issuesFilterStore={issuesFilterStore}
|
||||
key={weekIndex}
|
||||
week={week}
|
||||
issues={issues}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
enableQuickIssueCreate
|
||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||
quickActions={quickActions}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{layout === "week" && (
|
||||
<CalendarWeekDays
|
||||
issuesFilterStore={issuesFilterStore}
|
||||
week={issueCalendarView.allDaysOfActiveWeek}
|
||||
issues={issues}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
enableQuickIssueCreate
|
||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||
quickActions={quickActions}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -91,7 +91,7 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
|
||||
snapshot.isDraggingOver || date.date.getDay() === 0 || date.date.getDay() === 6
|
||||
? "bg-custom-background-90"
|
||||
: "bg-custom-background-100"
|
||||
} ${calendarLayout === "month" ? "min-h-[9rem]" : ""}`}
|
||||
} ${calendarLayout === "month" ? "min-h-[5rem]" : ""}`}
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
|
@ -13,7 +13,7 @@ export const CalendarWeekHeader: React.FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative grid divide-x-[0.5px] divide-custom-border-200 text-sm font-medium ${
|
||||
className={`relative sticky top-0 z-[1] grid divide-x-[0.5px] divide-custom-border-200 text-sm font-medium ${
|
||||
showWeekends ? "grid-cols-7" : "grid-cols-5"
|
||||
}`}
|
||||
>
|
||||
|
@ -5,12 +5,9 @@ import { observer } from "mobx-react-lite";
|
||||
import { useIssues, useUser } from "hooks/store";
|
||||
// components
|
||||
import { GanttQuickAddIssueForm, IssueGanttBlock } from "components/issues";
|
||||
import {
|
||||
GanttChartRoot,
|
||||
IBlockUpdateData,
|
||||
renderIssueBlocksStructure,
|
||||
IssueGanttSidebar,
|
||||
} from "components/gantt-chart";
|
||||
import { GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "components/gantt-chart";
|
||||
// helpers
|
||||
import { renderIssueBlocksStructure } from "helpers/issue.helper";
|
||||
// types
|
||||
import { TIssue, TUnGroupedIssues } from "@plane/types";
|
||||
import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle";
|
||||
|
@ -225,9 +225,15 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
let _kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || [];
|
||||
if (_kanbanFilters.includes(value)) _kanbanFilters = _kanbanFilters.filter((_value) => _value != value);
|
||||
else _kanbanFilters.push(value);
|
||||
issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.KANBAN_FILTERS, {
|
||||
[toggle]: _kanbanFilters,
|
||||
});
|
||||
issuesFilter.updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.KANBAN_FILTERS,
|
||||
{
|
||||
[toggle]: _kanbanFilters,
|
||||
},
|
||||
viewId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -138,6 +138,7 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
|
||||
<Plus height={14} width={14} strokeWidth={2} />
|
||||
</span>
|
||||
}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
|
@ -1,11 +1,10 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import { differenceInCalendarDays } from "date-fns";
|
||||
import { Layers, Link, Paperclip } from "lucide-react";
|
||||
import xor from "lodash/xor";
|
||||
// hooks
|
||||
import { useEventTracker, useEstimate, useLabel, useIssues } from "hooks/store";
|
||||
import { useEventTracker, useEstimate, useLabel, useIssues, useProjectState } from "hooks/store";
|
||||
// components
|
||||
import { IssuePropertyLabels } from "../properties/labels";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
@ -21,6 +20,8 @@ import {
|
||||
} from "components/dropdowns";
|
||||
// helpers
|
||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
import { shouldHighlightIssueDueDate } from "helpers/issue.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types";
|
||||
// constants
|
||||
@ -47,11 +48,14 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const {
|
||||
issues: { addIssueToCycle, removeIssueFromCycle },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { areEstimatesEnabledForCurrentProject } = useEstimate();
|
||||
const { getStateById } = useProjectState();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, cycleId, moduleId } = router.query;
|
||||
const { areEstimatesEnabledForCurrentProject } = useEstimate();
|
||||
const currentLayout = `${activeLayout} layout`;
|
||||
// derived values
|
||||
const stateDetails = getStateById(issue.state_id);
|
||||
|
||||
const issueOperations = useMemo(
|
||||
() => ({
|
||||
@ -231,8 +235,6 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const maxDate = issue.target_date ? new Date(issue.target_date) : null;
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* basic properties */}
|
||||
@ -300,7 +302,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
minDate={minDate ?? undefined}
|
||||
placeholder="Due date"
|
||||
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
|
||||
buttonClassName={targetDateDistance <= 0 ? "text-red-500" : ""}
|
||||
buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""}
|
||||
clearIconClassName="!text-custom-text-100"
|
||||
disabled={isReadOnly}
|
||||
showTooltip
|
||||
@ -378,12 +380,17 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="sub_issue_count"
|
||||
shouldRenderProperty={(properties) => !!properties.sub_issue_count}
|
||||
shouldRenderProperty={(properties) => !!properties.sub_issue_count && !!issue.sub_issues_count}
|
||||
>
|
||||
<Tooltip tooltipHeading="Sub-issues" tooltipContent={`${issue.sub_issues_count}`}>
|
||||
<div
|
||||
onClick={redirectToIssueDetail}
|
||||
className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 px-2.5 py-1 cursor-pointer"
|
||||
onClick={issue.sub_issues_count ? redirectToIssueDetail : () => {}}
|
||||
className={cn(
|
||||
"flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1",
|
||||
{
|
||||
"hover:bg-custom-background-80 cursor-pointer": issue.sub_issues_count,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Layers className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
|
||||
<div className="text-xs">{issue.sub_issues_count}</div>
|
||||
@ -395,7 +402,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="attachment_count"
|
||||
shouldRenderProperty={(properties) => !!properties.attachment_count}
|
||||
shouldRenderProperty={(properties) => !!properties.attachment_count && !!issue.attachment_count}
|
||||
>
|
||||
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
|
||||
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
|
||||
@ -409,7 +416,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="link"
|
||||
shouldRenderProperty={(properties) => !!properties.link}
|
||||
shouldRenderProperty={(properties) => !!properties.link && !!issue.link_count}
|
||||
>
|
||||
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
|
||||
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
|
||||
|
@ -3,6 +3,7 @@ import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
import size from "lodash/size";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
// hooks
|
||||
import { useCycle, useIssues } from "hooks/store";
|
||||
// components
|
||||
@ -89,7 +90,12 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
<>
|
||||
<TransferIssuesModal handleClose={() => setTransferIssuesModal(false)} isOpen={transferIssuesModal} />
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
{cycleStatus === "completed" && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />}
|
||||
{cycleStatus === "completed" && (
|
||||
<TransferIssues
|
||||
handleClick={() => setTransferIssuesModal(true)}
|
||||
disabled={!isEmpty(cycleDetails?.progress_snapshot) ?? false}
|
||||
/>
|
||||
)}
|
||||
<CycleAppliedFiltersRoot />
|
||||
|
||||
{issues?.groupedIssueIds?.length === 0 ? (
|
||||
|
@ -1,13 +1,15 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import differenceInCalendarDays from "date-fns/differenceInCalendarDays";
|
||||
// hooks
|
||||
import { useProjectState } from "hooks/store";
|
||||
// components
|
||||
import { DateDropdown } from "components/dropdowns";
|
||||
// helpers
|
||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
import { shouldHighlightIssueDueDate } from "helpers/issue.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { cn } from "helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
issue: TIssue;
|
||||
@ -18,8 +20,10 @@ type Props = {
|
||||
|
||||
export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props) => {
|
||||
const { issue, onChange, disabled, onClose } = props;
|
||||
|
||||
const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1;
|
||||
// store hooks
|
||||
const { getStateById } = useProjectState();
|
||||
// derived values
|
||||
const stateDetails = getStateById(issue.state_id);
|
||||
|
||||
return (
|
||||
<div className="h-11 border-b-[0.5px] border-custom-border-200">
|
||||
@ -42,7 +46,7 @@ export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props)
|
||||
buttonVariant="transparent-with-text"
|
||||
buttonContainerClassName="w-full"
|
||||
buttonClassName={cn("rounded-none text-left", {
|
||||
"text-red-500": targetDateDistance <= 0,
|
||||
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
|
||||
})}
|
||||
clearIconClassName="!text-custom-text-100"
|
||||
onClose={onClose}
|
||||
|
@ -65,15 +65,16 @@ export const HeaderColumn = (props: Props) => {
|
||||
</div>
|
||||
}
|
||||
onMenuClose={onClose}
|
||||
placement="bottom-end"
|
||||
placement="bottom-start"
|
||||
closeOnSelect
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}>
|
||||
<div
|
||||
className={`flex items-center justify-between gap-1.5 px-1 ${selectedMenuItem === `${propertyDetails.ascendingOrderKey}_${property}`
|
||||
? "text-custom-text-100"
|
||||
: "text-custom-text-200 hover:text-custom-text-100"
|
||||
}`}
|
||||
className={`flex items-center justify-between gap-1.5 px-1 ${
|
||||
selectedMenuItem === `${propertyDetails.ascendingOrderKey}_${property}`
|
||||
? "text-custom-text-100"
|
||||
: "text-custom-text-200 hover:text-custom-text-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowDownWideNarrow className="h-3 w-3 stroke-[1.5]" />
|
||||
@ -87,10 +88,11 @@ export const HeaderColumn = (props: Props) => {
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.descendingOrderKey, property)}>
|
||||
<div
|
||||
className={`flex items-center justify-between gap-1.5 px-1 ${selectedMenuItem === `${propertyDetails.descendingOrderKey}_${property}`
|
||||
? "text-custom-text-100"
|
||||
: "text-custom-text-200 hover:text-custom-text-100"
|
||||
}`}
|
||||
className={`flex items-center justify-between gap-1.5 px-1 ${
|
||||
selectedMenuItem === `${propertyDetails.descendingOrderKey}_${property}`
|
||||
? "text-custom-text-100"
|
||||
: "text-custom-text-200 hover:text-custom-text-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpNarrowWide className="h-3 w-3 stroke-[1.5]" />
|
||||
|
@ -5,6 +5,8 @@ import { useRouter } from "next/router";
|
||||
import { useApplication } from "hooks/store";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
issue: TIssue;
|
||||
@ -30,8 +32,13 @@ export const SpreadsheetSubIssueColumn: React.FC<Props> = observer((props: Props
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={redirectToIssueDetail}
|
||||
className="flex h-11 w-full items-center px-2.5 py-1 text-xs border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80 cursor-pointer"
|
||||
onClick={issue?.sub_issues_count ? redirectToIssueDetail : () => {}}
|
||||
className={cn(
|
||||
"flex h-11 w-full items-center px-2.5 py-1 text-xs border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80",
|
||||
{
|
||||
"cursor-pointer": issue?.sub_issues_count,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{issue?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
|
@ -19,10 +19,10 @@ export const SpreadsheetHeader = (props: Props) => {
|
||||
const { displayProperties, displayFilters, handleDisplayFilterUpdate, isEstimateEnabled } = props;
|
||||
|
||||
return (
|
||||
<thead className="sticky top-0 left-0 z-[1] border-b-[0.5px] border-custom-border-100">
|
||||
<thead className="sticky top-0 left-0 z-[2] border-b-[0.5px] border-custom-border-100">
|
||||
<tr>
|
||||
<th
|
||||
className="sticky left-0 z-[1] h-11 w-[28rem] flex items-center bg-custom-background-90 text-sm font-medium before:absolute before:h-full before:right-0 before:border-[0.5px] before:border-custom-border-100"
|
||||
className="sticky left-0 z-[2] h-11 w-[28rem] flex items-center bg-custom-background-90 text-sm font-medium before:absolute before:h-full before:right-0 before:border-[0.5px] before:border-custom-border-100"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
|
||||
|
@ -64,6 +64,31 @@ export interface IssueFormProps {
|
||||
const aiService = new AIService();
|
||||
const fileService = new FileService();
|
||||
|
||||
const TAB_INDICES = [
|
||||
"name",
|
||||
"description_html",
|
||||
"feeling_lucky",
|
||||
"ai_assistant",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
"label_ids",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"estimate_point",
|
||||
"parent_id",
|
||||
"create_more",
|
||||
"discard_button",
|
||||
"draft_button",
|
||||
"submit_button",
|
||||
"project_id",
|
||||
"remove_parent",
|
||||
];
|
||||
|
||||
const getTabIndex = (key: string) => TAB_INDICES.findIndex((tabIndex) => tabIndex === key) + 1;
|
||||
|
||||
export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
const {
|
||||
data,
|
||||
@ -271,7 +296,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
// TODO: update tabIndex logic
|
||||
tabIndex={19}
|
||||
tabIndex={getTabIndex("project_id")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -294,15 +319,18 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
|
||||
</span>
|
||||
<span className="truncate font-medium">{selectedParentIssue.name.substring(0, 50)}</span>
|
||||
<X
|
||||
className="h-3 w-3 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center"
|
||||
onClick={() => {
|
||||
setValue("parent_id", null);
|
||||
handleFormChange();
|
||||
setSelectedParentIssue(null);
|
||||
}}
|
||||
tabIndex={20}
|
||||
/>
|
||||
tabIndex={getTabIndex("remove_parent")}
|
||||
>
|
||||
<X className="h-3 w-3 cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -332,7 +360,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder="Issue Title"
|
||||
className="resize-none text-xl w-full"
|
||||
tabIndex={1}
|
||||
tabIndex={getTabIndex("name")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -346,7 +374,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
}`}
|
||||
onClick={handleAutoGenerateDescription}
|
||||
disabled={iAmFeelingLucky}
|
||||
tabIndex={3}
|
||||
tabIndex={getTabIndex("feeling_lucky")}
|
||||
>
|
||||
{iAmFeelingLucky ? (
|
||||
"Generating response"
|
||||
@ -375,7 +403,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
|
||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||
tabIndex={4}
|
||||
tabIndex={getTabIndex("ai_assistant")}
|
||||
>
|
||||
<Sparkle className="h-4 w-4" />
|
||||
AI
|
||||
@ -426,7 +454,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
}}
|
||||
projectId={projectId}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={6}
|
||||
tabIndex={getTabIndex("state_id")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -443,7 +471,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={7}
|
||||
tabIndex={getTabIndex("priority")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -464,7 +492,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||
placeholder="Assignees"
|
||||
multiple
|
||||
tabIndex={8}
|
||||
tabIndex={getTabIndex("assignee_ids")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -482,7 +510,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
handleFormChange();
|
||||
}}
|
||||
projectId={projectId}
|
||||
tabIndex={9}
|
||||
tabIndex={getTabIndex("label_ids")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -498,6 +526,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
buttonVariant="border-with-text"
|
||||
maxDate={maxDate ?? undefined}
|
||||
placeholder="Start date"
|
||||
tabIndex={getTabIndex("start_date")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -513,6 +542,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
buttonVariant="border-with-text"
|
||||
minDate={minDate ?? undefined}
|
||||
placeholder="Due date"
|
||||
tabIndex={getTabIndex("target_date")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -531,7 +561,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
}}
|
||||
value={value}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={11}
|
||||
tabIndex={getTabIndex("cycle_id")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -551,7 +581,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={12}
|
||||
tabIndex={getTabIndex("module_ids")}
|
||||
multiple
|
||||
showCount
|
||||
/>
|
||||
@ -573,7 +603,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
}}
|
||||
projectId={projectId}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={13}
|
||||
tabIndex={getTabIndex("estimate_point")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -603,7 +633,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
</button>
|
||||
}
|
||||
placement="bottom-start"
|
||||
tabIndex={14}
|
||||
tabIndex={getTabIndex("parent_id")}
|
||||
>
|
||||
{watch("parent_id") ? (
|
||||
<>
|
||||
@ -653,7 +683,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
|
||||
}}
|
||||
tabIndex={15}
|
||||
tabIndex={getTabIndex("create_more")}
|
||||
>
|
||||
<div className="flex cursor-pointer items-center justify-center">
|
||||
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
|
||||
@ -661,7 +691,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
<span className="text-xs">Create more</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={16}>
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={getTabIndex("discard_button")}>
|
||||
Discard
|
||||
</Button>
|
||||
|
||||
@ -673,7 +703,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit((data) => handleFormSubmit({ ...data, is_draft: false }))}
|
||||
tabIndex={17}
|
||||
tabIndex={getTabIndex("draft_button")}
|
||||
>
|
||||
{isSubmitting ? "Moving" : "Move from draft"}
|
||||
</Button>
|
||||
@ -683,7 +713,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit((data) => handleFormSubmit(data, true))}
|
||||
tabIndex={17}
|
||||
tabIndex={getTabIndex("draft_button")}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save as draft"}
|
||||
</Button>
|
||||
@ -691,7 +721,13 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
<Button variant="primary" type="submit" size="sm" loading={isSubmitting} tabIndex={isDraft ? 18 : 17}>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
tabIndex={isDraft ? getTabIndex("submit_button") : getTabIndex("draft_button")}
|
||||
>
|
||||
{data?.id ? (isSubmitting ? "Updating" : "Update issue") : isSubmitting ? "Creating" : "Create issue"}
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -183,7 +183,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
if (!workspaceSlug || !payload.project_id || !data?.id) return;
|
||||
|
||||
try {
|
||||
const response = await currentIssueStore.updateIssue(workspaceSlug, payload.project_id, data.id, payload, viewId);
|
||||
await currentIssueStore.updateIssue(workspaceSlug, payload.project_id, data.id, payload, viewId);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
@ -191,11 +191,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...response, state: "SUCCESS" },
|
||||
payload: { ...payload, issueId: data.id, state: "SUCCESS" },
|
||||
path: router.asPath,
|
||||
});
|
||||
handleClose();
|
||||
return response;
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { differenceInCalendarDays } from "date-fns";
|
||||
import { Signal, Tag, Triangle, LayoutPanelTop, CircleDot, CopyPlus, XCircle, CalendarDays } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail, useProject } from "hooks/store";
|
||||
import { useIssueDetail, useProject, useProjectState } from "hooks/store";
|
||||
// ui icons
|
||||
import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon, RelatedIcon } from "@plane/ui";
|
||||
import {
|
||||
@ -26,6 +25,7 @@ import {
|
||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
import { shouldHighlightIssueDueDate } from "helpers/issue.helper";
|
||||
|
||||
interface IPeekOverviewProperties {
|
||||
workspaceSlug: string;
|
||||
@ -42,11 +42,13 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getStateById } = useProjectState();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
if (!issue) return <></>;
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
const isEstimateEnabled = projectDetails?.estimate;
|
||||
const stateDetails = getStateById(issue.state_id);
|
||||
|
||||
const minDate = issue.start_date ? new Date(issue.start_date) : null;
|
||||
minDate?.setDate(minDate.getDate());
|
||||
@ -54,8 +56,6 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
const maxDate = issue.target_date ? new Date(issue.target_date) : null;
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1;
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<h6 className="text-sm font-medium">Properties</h6>
|
||||
@ -169,7 +169,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={cn("text-sm", {
|
||||
"text-custom-text-400": !issue.target_date,
|
||||
"text-red-500": targetDateDistance <= 0,
|
||||
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
|
||||
})}
|
||||
hideIcon
|
||||
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
|
||||
|
@ -15,7 +15,6 @@ import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker";
|
||||
|
||||
interface IIssuePeekOverview {
|
||||
is_archived?: boolean;
|
||||
onIssueUpdate?: (issue: Partial<TIssue>) => Promise<void>;
|
||||
}
|
||||
|
||||
export type TIssuePeekOperations = {
|
||||
@ -46,7 +45,7 @@ export type TIssuePeekOperations = {
|
||||
};
|
||||
|
||||
export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
const { is_archived = false, onIssueUpdate } = props;
|
||||
const { is_archived = false } = props;
|
||||
// hooks
|
||||
const { setToastAlert } = useToast();
|
||||
// router
|
||||
@ -87,7 +86,6 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
) => {
|
||||
try {
|
||||
const response = await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
if (onIssueUpdate) await onIssueUpdate(response);
|
||||
if (showToast)
|
||||
setToastAlert({
|
||||
title: "Issue updated successfully",
|
||||
@ -96,7 +94,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" },
|
||||
payload: { ...data, issueId, state: "SUCCESS", element: "Issue peek-overview" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
change_details: Object.values(data).join(","),
|
||||
@ -314,7 +312,6 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
removeIssueFromModule,
|
||||
removeModulesFromIssue,
|
||||
setToastAlert,
|
||||
onIssueUpdate,
|
||||
captureIssueEvent,
|
||||
router.asPath,
|
||||
]
|
||||
|
@ -154,22 +154,22 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200`
|
||||
} group flex w-full cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-custom-text-200`
|
||||
}
|
||||
value={label.id}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex w-full justify-between gap-2 rounded">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<div className="flex items-center justify-start gap-2 truncate">
|
||||
<span
|
||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color,
|
||||
}}
|
||||
/>
|
||||
<span>{label.name}</span>
|
||||
<span className="truncate">{label.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded p-1">
|
||||
<div className="flex shrink-0 items-center justify-center rounded p-1">
|
||||
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -235,7 +235,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
</button>
|
||||
))}
|
||||
|
||||
<CustomMenu verticalEllipsis buttonClassName="z-[1]">
|
||||
<CustomMenu verticalEllipsis buttonClassName="z-[1]" placement="bottom-end">
|
||||
{isEditingAllowed && (
|
||||
<>
|
||||
<CustomMenu.MenuItem onClick={handleEditModule}>
|
||||
|
@ -190,6 +190,7 @@ export const UserDetails: React.FC<Props> = observer((props) => {
|
||||
hasError={Boolean(errors.first_name)}
|
||||
placeholder="Enter your full name..."
|
||||
className="w-full border-onboarding-border-100 focus:border-custom-primary-100"
|
||||
maxLength={24}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -148,10 +148,14 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
|
||||
memberDetails?.member.last_name
|
||||
} ${memberDetails?.member.display_name.toLowerCase()}`,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={memberDetails?.member.display_name} src={memberDetails?.member.avatar} />
|
||||
{memberDetails?.member.display_name} (
|
||||
{memberDetails?.member.first_name + " " + memberDetails?.member.last_name})
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="flex-shrink-0 pt-0.5">
|
||||
<Avatar name={memberDetails?.member.display_name} src={memberDetails?.member.avatar} />
|
||||
</div>
|
||||
<div className="truncate">
|
||||
{memberDetails?.member.display_name} (
|
||||
{memberDetails?.member.first_name + " " + memberDetails?.member.last_name})
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
@ -11,6 +11,7 @@ import useToast from "hooks/use-toast";
|
||||
import { CreateProjectModal, ProjectSidebarListItem } from "components/project";
|
||||
// helpers
|
||||
import { copyUrlToClipboard } from "helpers/string.helper";
|
||||
import { cn } from "helpers/common.helper";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
@ -109,9 +110,9 @@ export const ProjectSidebarList: FC = observer(() => {
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`h-full space-y-2 overflow-y-auto pl-4 vertical-scrollbar scrollbar-md ${
|
||||
isScrolled ? "border-t border-custom-sidebar-border-300" : ""
|
||||
}`}
|
||||
className={cn("h-full space-y-2 overflow-y-auto px-4 vertical-scrollbar scrollbar-md", {
|
||||
"border-t border-custom-sidebar-border-300": isScrolled,
|
||||
})}
|
||||
>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="favorite-projects">
|
||||
|
@ -80,7 +80,7 @@ export const ProjectViewListItem: React.FC<Props> = observer((props) => {
|
||||
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
|
||||
<div className="group border-b border-custom-border-200 hover:bg-custom-background-90">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}>
|
||||
<div className="relative flex w-full items-center justify-between rounded p-4">
|
||||
<div className="relative flex h-[52px] w-full items-center justify-between rounded p-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-4 overflow-hidden">
|
||||
<div className="flex flex-col overflow-hidden ">
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user