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,
|
RelatedIssueSerializer,
|
||||||
IssuePublicSerializer,
|
IssuePublicSerializer,
|
||||||
IssueDetailSerializer,
|
IssueDetailSerializer,
|
||||||
|
IssueReactionLiteSerializer,
|
||||||
|
IssueAttachmentLiteSerializer,
|
||||||
|
IssueLinkLiteSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .module import (
|
from .module import (
|
||||||
|
@ -58,9 +58,12 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
LabelSerializer,
|
LabelSerializer,
|
||||||
CycleIssueSerializer,
|
CycleIssueSerializer,
|
||||||
IssueFlatSerializer,
|
IssueLiteSerializer,
|
||||||
IssueRelationSerializer,
|
IssueRelationSerializer,
|
||||||
InboxIssueLiteSerializer
|
InboxIssueLiteSerializer,
|
||||||
|
IssueReactionLiteSerializer,
|
||||||
|
IssueAttachmentLiteSerializer,
|
||||||
|
IssueLinkLiteSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Expansion mapper
|
# Expansion mapper
|
||||||
@ -79,12 +82,34 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||||||
"assignees": UserLiteSerializer,
|
"assignees": UserLiteSerializer,
|
||||||
"labels": LabelSerializer,
|
"labels": LabelSerializer,
|
||||||
"issue_cycle": CycleIssueSerializer,
|
"issue_cycle": CycleIssueSerializer,
|
||||||
"parent": IssueSerializer,
|
"parent": IssueLiteSerializer,
|
||||||
"issue_relation": IssueRelationSerializer,
|
"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
|
return self.fields
|
||||||
|
|
||||||
@ -105,7 +130,11 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||||||
LabelSerializer,
|
LabelSerializer,
|
||||||
CycleIssueSerializer,
|
CycleIssueSerializer,
|
||||||
IssueRelationSerializer,
|
IssueRelationSerializer,
|
||||||
InboxIssueLiteSerializer
|
InboxIssueLiteSerializer,
|
||||||
|
IssueLiteSerializer,
|
||||||
|
IssueReactionLiteSerializer,
|
||||||
|
IssueAttachmentLiteSerializer,
|
||||||
|
IssueLinkLiteSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Expansion mapper
|
# Expansion mapper
|
||||||
@ -124,9 +153,13 @@ class DynamicBaseSerializer(BaseSerializer):
|
|||||||
"assignees": UserLiteSerializer,
|
"assignees": UserLiteSerializer,
|
||||||
"labels": LabelSerializer,
|
"labels": LabelSerializer,
|
||||||
"issue_cycle": CycleIssueSerializer,
|
"issue_cycle": CycleIssueSerializer,
|
||||||
"parent": IssueSerializer,
|
"parent": IssueLiteSerializer,
|
||||||
"issue_relation": IssueRelationSerializer,
|
"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
|
# Check if field in expansion then expand the field
|
||||||
if expand in expansion:
|
if expand in expansion:
|
||||||
|
@ -444,6 +444,22 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
return IssueLink.objects.create(**validated_data)
|
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 IssueAttachmentSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IssueAttachment
|
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):
|
class IssueReactionSerializer(BaseSerializer):
|
||||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
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 CommentReactionSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CommentReaction
|
model = CommentReaction
|
||||||
@ -558,15 +601,15 @@ class IssueSerializer(DynamicBaseSerializer):
|
|||||||
# ids
|
# ids
|
||||||
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||||
module_ids = serializers.ListField(
|
module_ids = serializers.ListField(
|
||||||
child=serializers.UUIDField(), required=False, allow_null=True
|
child=serializers.UUIDField(), required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Many to many
|
# Many to many
|
||||||
label_ids = serializers.ListField(
|
label_ids = serializers.ListField(
|
||||||
child=serializers.UUIDField(), required=False, allow_null=True
|
child=serializers.UUIDField(), required=False,
|
||||||
)
|
)
|
||||||
assignee_ids = serializers.ListField(
|
assignee_ids = serializers.ListField(
|
||||||
child=serializers.UUIDField(), required=False, allow_null=True
|
child=serializers.UUIDField(), required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Count items
|
# Count items
|
||||||
@ -606,48 +649,39 @@ class IssueSerializer(DynamicBaseSerializer):
|
|||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class IssueDetailSerializer(IssueSerializer):
|
class IssueDetailSerializer(IssueSerializer):
|
||||||
description_html = serializers.CharField()
|
description_html = serializers.CharField()
|
||||||
is_subscribed = serializers.BooleanField(read_only=True)
|
is_subscribed = serializers.BooleanField(read_only=True)
|
||||||
|
|
||||||
class Meta(IssueSerializer.Meta):
|
class Meta(IssueSerializer.Meta):
|
||||||
fields = IssueSerializer.Meta.fields + ["description_html", "is_subscribed"]
|
fields = IssueSerializer.Meta.fields + [
|
||||||
|
"description_html",
|
||||||
|
"is_subscribed",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class IssueLiteSerializer(DynamicBaseSerializer):
|
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:
|
class Meta:
|
||||||
model = Issue
|
model = Issue
|
||||||
fields = "__all__"
|
fields = [
|
||||||
read_only_fields = [
|
"id",
|
||||||
"start_date",
|
"sequence_id",
|
||||||
"target_date",
|
"project_id",
|
||||||
"completed_at",
|
|
||||||
"workspace",
|
|
||||||
"project",
|
|
||||||
"created_by",
|
|
||||||
"updated_by",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
]
|
||||||
|
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):
|
class IssuePublicSerializer(BaseSerializer):
|
||||||
|
@ -3,7 +3,7 @@ import json
|
|||||||
|
|
||||||
# Django import
|
# Django import
|
||||||
from django.utils import timezone
|
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.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.contrib.postgres.aggregates import ArrayAgg
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
@ -25,13 +25,14 @@ from plane.db.models import (
|
|||||||
IssueLink,
|
IssueLink,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
ProjectMember,
|
ProjectMember,
|
||||||
|
IssueReaction,
|
||||||
|
IssueSubscriber,
|
||||||
)
|
)
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
|
IssueCreateSerializer,
|
||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
InboxSerializer,
|
InboxSerializer,
|
||||||
InboxIssueSerializer,
|
InboxIssueSerializer,
|
||||||
IssueCreateSerializer,
|
|
||||||
IssueDetailSerializer,
|
|
||||||
)
|
)
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
@ -295,11 +296,7 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
issue_data = request.data.pop("issue", False)
|
issue_data = request.data.pop("issue", False)
|
||||||
|
|
||||||
if bool(issue_data):
|
if bool(issue_data):
|
||||||
issue = Issue.objects.get(
|
issue = self.get_queryset().filter(pk=inbox_issue.issue_id).first()
|
||||||
pk=inbox_issue.issue_id,
|
|
||||||
workspace__slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
)
|
|
||||||
# Only allow guests and viewers to edit name and description
|
# Only allow guests and viewers to edit name and description
|
||||||
if project_member.role <= 10:
|
if project_member.role <= 10:
|
||||||
# viewers and guests since only viewers and guests
|
# viewers and guests since only viewers and guests
|
||||||
@ -385,9 +382,7 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
if state is not None:
|
if state is not None:
|
||||||
issue.state = state
|
issue.state = state
|
||||||
issue.save()
|
issue.save()
|
||||||
issue = self.get_queryset().filter(pk=issue_id).first()
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
serializer = IssueSerializer(issue, expand=self.expand)
|
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
||||||
return Response(
|
return Response(
|
||||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
@ -397,11 +392,45 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
|
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
|
||||||
issue = self.get_queryset().filter(pk=issue_id).first()
|
issue = (
|
||||||
serializer = IssueDetailSerializer(
|
self.get_queryset()
|
||||||
issue,
|
.filter(pk=issue_id)
|
||||||
expand=self.expand,
|
.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)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, inbox_id, issue_id):
|
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)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk=None):
|
def retrieve(self, request, slug, project_id, pk=None):
|
||||||
issue = self.get_queryset().filter(pk=pk).first()
|
issue = (
|
||||||
return Response(
|
self.get_queryset()
|
||||||
IssueDetailSerializer(
|
.filter(pk=pk)
|
||||||
issue, fields=self.fields, expand=self.expand
|
.prefetch_related(
|
||||||
).data,
|
Prefetch(
|
||||||
status=status.HTTP_200_OK,
|
"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):
|
def partial_update(self, request, slug, project_id, pk=None):
|
||||||
issue = Issue.objects.get(
|
issue = self.get_queryset().filter(pk=pk).first()
|
||||||
workspace__slug=slug, project_id=project_id, pk=pk
|
|
||||||
)
|
if not issue:
|
||||||
|
return Response(
|
||||||
|
{"error": "Issue not found"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
current_instance = json.dumps(
|
current_instance = json.dumps(
|
||||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||||
)
|
)
|
||||||
|
|
||||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
||||||
serializer = IssueCreateSerializer(
|
serializer = IssueCreateSerializer(
|
||||||
issue, data=request.data, partial=True
|
issue, data=request.data, partial=True
|
||||||
@ -560,39 +601,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
notification=True,
|
notification=True,
|
||||||
origin=request.META.get("HTTP_ORIGIN"),
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
issue = (
|
issue = self.get_queryset().filter(pk=pk).first()
|
||||||
self.get_queryset()
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
.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)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, pk=None):
|
def destroy(self, request, slug, project_id, pk=None):
|
||||||
@ -1581,13 +1591,47 @@ class IssueArchiveViewSet(BaseViewSet):
|
|||||||
return Response(issues, status=status.HTTP_200_OK)
|
return Response(issues, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk=None):
|
def retrieve(self, request, slug, project_id, pk=None):
|
||||||
issue = self.get_queryset().filter(pk=pk).first()
|
issue = (
|
||||||
return Response(
|
self.get_queryset()
|
||||||
IssueDetailSerializer(
|
.filter(pk=pk)
|
||||||
issue, fields=self.fields, expand=self.expand
|
.prefetch_related(
|
||||||
).data,
|
Prefetch(
|
||||||
status=status.HTTP_200_OK,
|
"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):
|
def unarchive(self, request, slug, project_id, pk=None):
|
||||||
issue = Issue.objects.get(
|
issue = Issue.objects.get(
|
||||||
@ -2258,9 +2302,14 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def partial_update(self, request, slug, project_id, pk):
|
def partial_update(self, request, slug, project_id, pk):
|
||||||
issue = Issue.objects.get(
|
issue = self.get_queryset().filter(pk=pk).first()
|
||||||
workspace__slug=slug, project_id=project_id, pk=pk
|
|
||||||
)
|
if not issue:
|
||||||
|
return Response(
|
||||||
|
{"error": "Issue does not exist"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
serializer = IssueSerializer(issue, data=request.data, partial=True)
|
serializer = IssueSerializer(issue, data=request.data, partial=True)
|
||||||
|
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
@ -2286,17 +2335,52 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
notification=True,
|
notification=True,
|
||||||
origin=request.META.get("HTTP_ORIGIN"),
|
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)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def retrieve(self, request, slug, project_id, pk=None):
|
def retrieve(self, request, slug, project_id, pk=None):
|
||||||
issue = self.get_queryset().filter(pk=pk).first()
|
issue = (
|
||||||
return Response(
|
self.get_queryset()
|
||||||
IssueSerializer(
|
.filter(pk=pk)
|
||||||
issue, fields=self.fields, expand=self.expand
|
.prefetch_related(
|
||||||
).data,
|
Prefetch(
|
||||||
status=status.HTTP_200_OK,
|
"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):
|
def destroy(self, request, slug, project_id, pk=None):
|
||||||
issue = Issue.objects.get(
|
issue = Issue.objects.get(
|
||||||
|
@ -560,7 +560,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(
|
.filter(
|
||||||
workspace__slug=self.kwargs.get("slug"),
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
member__is_bot=False,
|
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
.select_related("workspace", "workspace__owner")
|
.select_related("workspace", "workspace__owner")
|
||||||
@ -768,7 +767,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
|
|||||||
project_ids = (
|
project_ids = (
|
||||||
ProjectMember.objects.filter(
|
ProjectMember.objects.filter(
|
||||||
member=request.user,
|
member=request.user,
|
||||||
member__is_bot=False,
|
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
.values_list("project_id", flat=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
|
# Get all the project members in which the user is involved
|
||||||
project_members = ProjectMember.objects.filter(
|
project_members = ProjectMember.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
member__is_bot=False,
|
|
||||||
project_id__in=project_ids,
|
project_id__in=project_ids,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
).select_related("project", "member", "workspace")
|
).select_related("project", "member", "workspace")
|
||||||
|
@ -10,6 +10,7 @@ from django.utils import timezone
|
|||||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils.html import strip_tags
|
from django.utils.html import strip_tags
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from plane.db.models import EmailNotificationLog, User, Issue
|
from plane.db.models import EmailNotificationLog, User, Issue
|
||||||
@ -301,5 +302,7 @@ def send_email_notification(
|
|||||||
print("Duplicate task recived. Skipping...")
|
print("Duplicate task recived. Skipping...")
|
||||||
return
|
return
|
||||||
except (Issue.DoesNotExist, User.DoesNotExist) as e:
|
except (Issue.DoesNotExist, User.DoesNotExist) as e:
|
||||||
|
if settings.DEBUG:
|
||||||
|
print(e)
|
||||||
release_lock(lock_id=lock_id)
|
release_lock(lock_id=lock_id)
|
||||||
return
|
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();
|
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3 }).run();
|
||||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
else editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unsetLinkEditor = (editor: Editor) => {
|
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 {
|
.resize-cursor {
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
|
@ -9,15 +9,15 @@
|
|||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 1rem;
|
||||||
border: 1px solid rgba(var(--color-border-200));
|
border: 2px solid rgba(var(--color-border-300));
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper table td,
|
.tableWrapper table td,
|
||||||
.tableWrapper table th {
|
.tableWrapper table th {
|
||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
border: 1px solid rgba(var(--color-border-200));
|
border: 1px solid rgba(var(--color-border-300));
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -43,7 +43,8 @@
|
|||||||
.tableWrapper table th {
|
.tableWrapper table th {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background-color: rgba(var(--color-primary-100));
|
background-color: #d9e4ff;
|
||||||
|
color: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper table th * {
|
.tableWrapper table th * {
|
||||||
@ -62,6 +63,35 @@
|
|||||||
pointer-events: none;
|
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 {
|
.tableWrapper table .column-resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -2px;
|
right: -2px;
|
||||||
@ -69,7 +99,7 @@
|
|||||||
bottom: -2px;
|
bottom: -2px;
|
||||||
width: 4px;
|
width: 4px;
|
||||||
z-index: 99;
|
z-index: 99;
|
||||||
background-color: rgba(var(--color-primary-400));
|
background-color: #d9e4ff;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,7 +142,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper .tableControls .rowsControlDiv {
|
.tableWrapper .tableControls .rowsControlDiv {
|
||||||
background-color: rgba(var(--color-primary-100));
|
background-color: #d9e4ff;
|
||||||
border: 1px solid rgba(var(--color-border-200));
|
border: 1px solid rgba(var(--color-border-200));
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background-size: 1.25rem;
|
background-size: 1.25rem;
|
||||||
@ -127,7 +157,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper .tableControls .columnsControlDiv {
|
.tableWrapper .tableControls .columnsControlDiv {
|
||||||
background-color: rgba(var(--color-primary-100));
|
background-color: #d9e4ff;
|
||||||
border: 1px solid rgba(var(--color-border-200));
|
border: 1px solid rgba(var(--color-border-200));
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background-size: 1.25rem;
|
background-size: 1.25rem;
|
||||||
@ -144,10 +174,12 @@
|
|||||||
.tableWrapper .tableControls .tableColorPickerToolbox {
|
.tableWrapper .tableControls .tableColorPickerToolbox {
|
||||||
border: 1px solid rgba(var(--color-border-300));
|
border: 1px solid rgba(var(--color-border-300));
|
||||||
background-color: rgba(var(--color-background-100));
|
background-color: rgba(var(--color-background-100));
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 200px;
|
width: max-content;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,7 +190,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.1rem;
|
padding: 0.3rem 0.5rem 0.1rem 0.1rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
@ -173,9 +205,7 @@
|
|||||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer,
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer,
|
||||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer,
|
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer,
|
||||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer {
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer {
|
||||||
border: 1px solid rgba(var(--color-border-300));
|
padding: 4px 0px;
|
||||||
border-radius: 3px;
|
|
||||||
padding: 4px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -187,8 +217,8 @@
|
|||||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg,
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg,
|
||||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg,
|
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg,
|
||||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg {
|
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg {
|
||||||
width: 2rem;
|
width: 1rem;
|
||||||
height: 2rem;
|
height: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableToolbox {
|
.tableToolbox {
|
||||||
|
@ -13,7 +13,7 @@ export const TableCell = Node.create<TableCellOptions>({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
content: "paragraph+",
|
content: "block+",
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
@ -33,7 +33,10 @@ export const TableCell = Node.create<TableCellOptions>({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
default: "none",
|
default: null,
|
||||||
|
},
|
||||||
|
textColor: {
|
||||||
|
default: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -50,7 +53,7 @@ export const TableCell = Node.create<TableCellOptions>({
|
|||||||
return [
|
return [
|
||||||
"td",
|
"td",
|
||||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||||
style: `background-color: ${node.attrs.background}`,
|
style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`,
|
||||||
}),
|
}),
|
||||||
0,
|
0,
|
||||||
];
|
];
|
||||||
|
@ -33,7 +33,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
background: {
|
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)*",
|
content: "(tableCell | tableHeader)*",
|
||||||
|
|
||||||
tableRole: "row",
|
tableRole: "row",
|
||||||
@ -22,6 +33,12 @@ export const TableRow = Node.create<TableRowOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
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 = {
|
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>`,
|
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>`,
|
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" 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>`,
|
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
|
insertLeftTableIcon: `<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
length={24}
|
length={24}
|
||||||
@ -35,6 +35,8 @@ export const icons = {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</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
|
insertBottomTableIcon: `<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
length={24}
|
length={24}
|
||||||
|
@ -81,53 +81,75 @@ const defaultTippyOptions: Partial<Props> = {
|
|||||||
placement: "right",
|
placement: "right",
|
||||||
};
|
};
|
||||||
|
|
||||||
function setCellsBackgroundColor(editor: Editor, backgroundColor: string) {
|
function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
|
||||||
return editor
|
return editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.updateAttributes("tableCell", {
|
.updateAttributes("tableCell", {
|
||||||
background: backgroundColor,
|
background: color.backgroundColor,
|
||||||
})
|
textColor: color.textColor,
|
||||||
.updateAttributes("tableHeader", {
|
|
||||||
background: backgroundColor,
|
|
||||||
})
|
})
|
||||||
.run();
|
.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[] = [
|
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,
|
icon: icons.insertLeftTableIcon,
|
||||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(),
|
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Add Column After",
|
label: "Add column after",
|
||||||
icon: icons.insertRightTableIcon,
|
icon: icons.insertRightTableIcon,
|
||||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(),
|
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Pick Column Color",
|
label: "Pick color",
|
||||||
icon: icons.colorPicker,
|
icon: "", // No icon needed for color picker
|
||||||
action: ({
|
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
|
||||||
editor,
|
|
||||||
triggerButton,
|
|
||||||
controlsContainer,
|
|
||||||
}: {
|
|
||||||
editor: Editor;
|
|
||||||
triggerButton: HTMLElement;
|
|
||||||
controlsContainer: Element;
|
|
||||||
}) => {
|
|
||||||
createColorPickerToolbox({
|
|
||||||
triggerButton,
|
|
||||||
tippyOptions: {
|
|
||||||
appendTo: controlsContainer,
|
|
||||||
},
|
|
||||||
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Delete Column",
|
label: "Delete column",
|
||||||
icon: icons.deleteColumn,
|
icon: icons.deleteColumn,
|
||||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(),
|
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(),
|
||||||
},
|
},
|
||||||
@ -135,35 +157,24 @@ const columnsToolboxItems: ToolboxItem[] = [
|
|||||||
|
|
||||||
const rowsToolboxItems: 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,
|
icon: icons.insertTopTableIcon,
|
||||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(),
|
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Add Row Below",
|
label: "Add row below",
|
||||||
icon: icons.insertBottomTableIcon,
|
icon: icons.insertBottomTableIcon,
|
||||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(),
|
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Pick Row Color",
|
label: "Pick color",
|
||||||
icon: icons.colorPicker,
|
icon: "",
|
||||||
action: ({
|
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
|
||||||
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: "Delete Row",
|
label: "Delete Row",
|
||||||
@ -176,37 +187,57 @@ function createToolbox({
|
|||||||
triggerButton,
|
triggerButton,
|
||||||
items,
|
items,
|
||||||
tippyOptions,
|
tippyOptions,
|
||||||
|
onSelectColor,
|
||||||
onClickItem,
|
onClickItem,
|
||||||
|
colors,
|
||||||
}: {
|
}: {
|
||||||
triggerButton: Element | null;
|
triggerButton: Element | null;
|
||||||
items: ToolboxItem[];
|
items: ToolboxItem[];
|
||||||
tippyOptions: any;
|
tippyOptions: any;
|
||||||
onClickItem: (item: ToolboxItem) => void;
|
onClickItem: (item: ToolboxItem) => void;
|
||||||
|
onSelectColor: (color: { backgroundColor: string; textColor: string }) => void;
|
||||||
|
colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } };
|
||||||
}): Instance<Props> {
|
}): Instance<Props> {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
const toolbox = tippy(triggerButton, {
|
const toolbox = tippy(triggerButton, {
|
||||||
content: h(
|
content: h(
|
||||||
"div",
|
"div",
|
||||||
{ className: "tableToolbox" },
|
{ className: "tableToolbox" },
|
||||||
items.map((item) =>
|
items.map((item, index) => {
|
||||||
h(
|
if (item.label === "Pick color") {
|
||||||
"div",
|
return h("div", { className: "flex flex-col" }, [
|
||||||
{
|
h("div", { className: "divider" }),
|
||||||
className: "toolboxItem",
|
h("div", { className: "colorPickerLabel" }, item.label),
|
||||||
itemType: "button",
|
h(
|
||||||
onClick() {
|
"div",
|
||||||
onClickItem(item);
|
{ 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", {
|
h("div", { className: "label" }, item.label),
|
||||||
className: "iconContainer",
|
]
|
||||||
innerHTML: item.icon,
|
);
|
||||||
}),
|
}
|
||||||
h("div", { className: "label" }, item.label),
|
})
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
...tippyOptions,
|
...tippyOptions,
|
||||||
});
|
});
|
||||||
@ -214,71 +245,6 @@ function createToolbox({
|
|||||||
return Array.isArray(toolbox) ? toolbox[0] : toolbox;
|
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 {
|
export class TableView implements NodeView {
|
||||||
node: ProseMirrorNode;
|
node: ProseMirrorNode;
|
||||||
cellMinWidth: number;
|
cellMinWidth: number;
|
||||||
@ -347,10 +313,27 @@ export class TableView implements NodeView {
|
|||||||
this.rowsControl,
|
this.rowsControl,
|
||||||
this.columnsControl
|
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({
|
this.columnsToolbox = createToolbox({
|
||||||
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
|
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
|
||||||
items: columnsToolboxItems,
|
items: columnsToolboxItems,
|
||||||
|
colors: columnColors,
|
||||||
|
onSelectColor: (color) => setCellsBackgroundColor(this.editor, color),
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
...defaultTippyOptions,
|
...defaultTippyOptions,
|
||||||
appendTo: this.controls,
|
appendTo: this.controls,
|
||||||
@ -368,10 +351,12 @@ export class TableView implements NodeView {
|
|||||||
this.rowsToolbox = createToolbox({
|
this.rowsToolbox = createToolbox({
|
||||||
triggerButton: this.rowsControl.firstElementChild,
|
triggerButton: this.rowsControl.firstElementChild,
|
||||||
items: rowsToolboxItems,
|
items: rowsToolboxItems,
|
||||||
|
colors: columnColors,
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
...defaultTippyOptions,
|
...defaultTippyOptions,
|
||||||
appendTo: this.controls,
|
appendTo: this.controls,
|
||||||
},
|
},
|
||||||
|
onSelectColor: (color) => setTableRowBackgroundColor(editor, color),
|
||||||
onClickItem: (item) => {
|
onClickItem: (item) => {
|
||||||
item.action({
|
item.action({
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
@ -383,8 +368,6 @@ export class TableView implements NodeView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Table
|
|
||||||
|
|
||||||
this.colgroup = h(
|
this.colgroup = h(
|
||||||
"colgroup",
|
"colgroup",
|
||||||
null,
|
null,
|
||||||
@ -437,16 +420,19 @@ export class TableView implements NodeView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateControls() {
|
updateControls() {
|
||||||
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => {
|
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce(
|
||||||
if (curr.spec.hoveredCell !== undefined) {
|
(acc, curr) => {
|
||||||
acc["hoveredCell"] = curr.spec.hoveredCell;
|
if (curr.spec.hoveredCell !== undefined) {
|
||||||
}
|
acc["hoveredCell"] = curr.spec.hoveredCell;
|
||||||
|
}
|
||||||
|
|
||||||
if (curr.spec.hoveredTable !== undefined) {
|
if (curr.spec.hoveredTable !== undefined) {
|
||||||
acc["hoveredTable"] = curr.spec.hoveredTable;
|
acc["hoveredTable"] = curr.spec.hoveredTable;
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, HTMLElement>) as any;
|
},
|
||||||
|
{} as Record<string, HTMLElement>
|
||||||
|
) as any;
|
||||||
|
|
||||||
if (table === undefined || cell === undefined) {
|
if (table === undefined || cell === undefined) {
|
||||||
return this.root.classList.add("controls--disabled");
|
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;
|
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement;
|
||||||
|
|
||||||
if (!this.table) {
|
if (!this.table || !cellDom) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableRect = this.table.getBoundingClientRect();
|
const tableRect = this.table?.getBoundingClientRect();
|
||||||
const cellRect = cellDom.getBoundingClientRect();
|
const cellRect = cellDom?.getBoundingClientRect();
|
||||||
|
|
||||||
if (this.columnsControl) {
|
if (this.columnsControl) {
|
||||||
this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`;
|
this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`;
|
||||||
|
@ -107,10 +107,9 @@ export const Table = Node.create({
|
|||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
insertTable:
|
insertTable:
|
||||||
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
|
({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>
|
||||||
({ tr, dispatch, editor }) => {
|
({ tr, dispatch, editor }) => {
|
||||||
const node = createTable(editor.schema, rows, cols, withHeaderRow);
|
const node = createTable(editor.schema, rows, cols, withHeaderRow);
|
||||||
|
|
||||||
if (dispatch) {
|
if (dispatch) {
|
||||||
const offset = tr.selection.anchor + 1;
|
const offset = tr.selection.anchor + 1;
|
||||||
|
|
||||||
|
@ -42,15 +42,6 @@ export function CoreEditorProps(
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
handleDrop: (view, event, _slice, moved) => {
|
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]) {
|
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const file = event.dataTransfer.files[0];
|
const file = event.dataTransfer.files[0];
|
||||||
|
@ -48,34 +48,12 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
|||||||
function getComplexItems(): BubbleMenuItem[] {
|
function getComplexItems(): BubbleMenuItem[] {
|
||||||
const items: BubbleMenuItem[] = [TableItem(editor)];
|
const items: BubbleMenuItem[] = [TableItem(editor)];
|
||||||
|
|
||||||
if (shouldShowImageItem()) {
|
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
|
||||||
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
const complexItems: BubbleMenuItem[] = getComplexItems();
|
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 (
|
return (
|
||||||
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
||||||
<div className="flex items-center gap-0.5 pr-2">
|
<div className="flex items-center gap-0.5 pr-2">
|
||||||
|
@ -35,7 +35,7 @@ export interface DragHandleOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function absoluteRect(node: Element) {
|
function absoluteRect(node: Element) {
|
||||||
const data = node.getBoundingClientRect();
|
const data = node?.getBoundingClientRect();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: data.top,
|
top: data.top,
|
||||||
@ -65,7 +65,7 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nodePosAtDOM(node: Element, view: EditorView) {
|
function nodePosAtDOM(node: Element, view: EditorView) {
|
||||||
const boundingRect = node.getBoundingClientRect();
|
const boundingRect = node?.getBoundingClientRect();
|
||||||
|
|
||||||
if (node.nodeName === "IMG") {
|
if (node.nodeName === "IMG") {
|
||||||
return view.posAtCoords({
|
return view.posAtCoords({
|
||||||
|
@ -60,34 +60,13 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
|||||||
function getComplexItems(): BubbleMenuItem[] {
|
function getComplexItems(): BubbleMenuItem[] {
|
||||||
const items: BubbleMenuItem[] = [TableItem(props.editor)];
|
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;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
const complexItems: BubbleMenuItem[] = getComplexItems();
|
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) => {
|
const handleAccessChange = (accessKey: string) => {
|
||||||
props.commentAccessSpecifier?.onAccessChange(accessKey);
|
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;
|
is_favorite: boolean;
|
||||||
issue: string;
|
issue: string;
|
||||||
name: string;
|
name: string;
|
||||||
owned_by: string;
|
owned_by_id: string;
|
||||||
progress_snapshot: TProgressSnapshot;
|
progress_snapshot: TProgressSnapshot;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
status: TCycleGroups;
|
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 { TIssuePriorities } from "../issues";
|
||||||
|
import { TIssueAttachment } from "./issue_attachment";
|
||||||
|
import { TIssueLink } from "./issue_link";
|
||||||
|
import { TIssueReaction } from "./issue_reaction";
|
||||||
|
|
||||||
// new issue structure types
|
// new issue structure types
|
||||||
export type TIssue = {
|
export type TIssue = {
|
||||||
@ -34,7 +37,12 @@ export type TIssue = {
|
|||||||
updated_by: string;
|
updated_by: string;
|
||||||
|
|
||||||
is_draft: boolean;
|
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 is used for optimistic updates. It is not a part of the API response.
|
||||||
tempId?: string;
|
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 = {
|
export type TIssueAttachment = {
|
||||||
id: string;
|
id: string;
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
attributes: {
|
attributes: {
|
||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
};
|
};
|
||||||
asset: string;
|
asset: string;
|
||||||
created_by: string;
|
issue_id: string;
|
||||||
|
|
||||||
|
//need
|
||||||
|
updated_at: string;
|
||||||
updated_by: string;
|
updated_by: string;
|
||||||
project: string;
|
|
||||||
workspace: string;
|
|
||||||
issue: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TIssueAttachmentMap = {
|
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 & {
|
export type TIssueLink = TIssueLinkEditableFields & {
|
||||||
created_at: Date;
|
created_by_id: string;
|
||||||
created_by: string;
|
|
||||||
created_by_detail: IUserLite;
|
|
||||||
id: string;
|
id: string;
|
||||||
metadata: any;
|
metadata: any;
|
||||||
|
issue_id: string;
|
||||||
|
|
||||||
|
//need
|
||||||
|
created_at: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TIssueLinkMap = {
|
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 = {
|
export type TIssueReaction = {
|
||||||
actor: string;
|
actor_id: string;
|
||||||
actor_detail: IUserLite;
|
|
||||||
created_at: Date;
|
|
||||||
created_by: string;
|
|
||||||
id: string;
|
id: string;
|
||||||
issue: string;
|
issue_id: string;
|
||||||
project: string;
|
|
||||||
reaction: string;
|
reaction: string;
|
||||||
updated_at: Date;
|
|
||||||
updated_by: string;
|
|
||||||
workspace: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TIssueReactionMap = {
|
export type TIssueReactionMap = {
|
||||||
|
@ -20,7 +20,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
|
|||||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||||
const projectDetails = projectId ? getProjectById(projectId.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;
|
const moduleLeadDetails = moduleDetails && moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -69,7 +69,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
|||||||
);
|
);
|
||||||
|
|
||||||
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
|
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(
|
const { data: activeCycleIssues } = useSWR(
|
||||||
workspaceSlug && projectId && currentProjectActiveCycleId
|
workspaceSlug && projectId && currentProjectActiveCycleId
|
||||||
|
@ -59,7 +59,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
const { getUserDetails } = useMember();
|
const { getUserDetails } = useMember();
|
||||||
// derived values
|
// derived values
|
||||||
const cycleDetails = getCycleById(cycleId);
|
const cycleDetails = getCycleById(cycleId);
|
||||||
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
|
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
|
||||||
// toast alert
|
// toast alert
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
// form info
|
// form info
|
||||||
|
@ -16,12 +16,13 @@ import { CYCLE_DETAILS } from "constants/fetch-keys";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
handleClick: () => void;
|
handleClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cycleService = new CycleService();
|
const cycleService = new CycleService();
|
||||||
|
|
||||||
export const TransferIssues: React.FC<Props> = (props) => {
|
export const TransferIssues: React.FC<Props> = (props) => {
|
||||||
const { handleClick } = props;
|
const { handleClick, disabled = false } = props;
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||||
@ -46,7 +47,12 @@ export const TransferIssues: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
{isEmpty(cycleDetails?.progress_snapshot) && transferableIssuesCount > 0 && (
|
{isEmpty(cycleDetails?.progress_snapshot) && transferableIssuesCount > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<Button variant="primary" prependIcon={<TransferIcon color="white" />} onClick={handleClick}>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
prependIcon={<TransferIcon color="white" />}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
Transfer Issues
|
Transfer Issues
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,7 +14,7 @@ import {
|
|||||||
IssueListItemProps,
|
IssueListItemProps,
|
||||||
} from "components/dashboard/widgets";
|
} from "components/dashboard/widgets";
|
||||||
// ui
|
// ui
|
||||||
import { getButtonStyling } from "@plane/ui";
|
import { Loader, getButtonStyling } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
import { getRedirectionFilters } from "helpers/dashboard.helper";
|
import { getRedirectionFilters } from "helpers/dashboard.helper";
|
||||||
@ -63,7 +63,12 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
|||||||
<>
|
<>
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
{isLoading ? (
|
{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 ? (
|
) : 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">
|
<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";
|
import { FC } from "react";
|
||||||
// hooks
|
// components
|
||||||
import { useIssueDetail } from "hooks/store";
|
import { GanttChartBlock } from "./block";
|
||||||
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";
|
|
||||||
// types
|
// types
|
||||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||||
// constants
|
// constants
|
||||||
import { BLOCK_HEIGHT, HEADER_HEIGHT } from "../constants";
|
import { HEADER_HEIGHT } from "../constants";
|
||||||
|
|
||||||
export type GanttChartBlocksProps = {
|
export type GanttChartBlocksProps = {
|
||||||
itemsContainerWidth: number;
|
itemsContainerWidth: number;
|
||||||
@ -21,10 +15,11 @@ export type GanttChartBlocksProps = {
|
|||||||
enableBlockRightResize: boolean;
|
enableBlockRightResize: boolean;
|
||||||
enableBlockMove: boolean;
|
enableBlockMove: boolean;
|
||||||
enableAddBlock: boolean;
|
enableAddBlock: boolean;
|
||||||
|
ganttContainerRef: React.RefObject<HTMLDivElement>;
|
||||||
showAllBlocks: boolean;
|
showAllBlocks: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GanttChartBlocksList: FC<GanttChartBlocksProps> = observer((props) => {
|
export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
|
||||||
const {
|
const {
|
||||||
itemsContainerWidth,
|
itemsContainerWidth,
|
||||||
blocks,
|
blocks,
|
||||||
@ -34,52 +29,9 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = observer((props)
|
|||||||
enableBlockRightResize,
|
enableBlockRightResize,
|
||||||
enableBlockMove,
|
enableBlockMove,
|
||||||
enableAddBlock,
|
enableAddBlock,
|
||||||
|
ganttContainerRef,
|
||||||
showAllBlocks,
|
showAllBlocks,
|
||||||
} = props;
|
} = 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 (
|
return (
|
||||||
<div
|
<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
|
// 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;
|
if (!showAllBlocks && !(block.start_date && block.target_date)) return;
|
||||||
|
|
||||||
const isBlockVisibleOnChart = block.start_date && block.target_date;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<GanttChartBlock
|
||||||
key={`block-${block.id}`}
|
block={block}
|
||||||
className="relative min-w-full w-max"
|
blockToRender={blockToRender}
|
||||||
style={{
|
blockUpdateHandler={blockUpdateHandler}
|
||||||
height: `${BLOCK_HEIGHT}px`,
|
enableBlockLeftResize={enableBlockLeftResize}
|
||||||
}}
|
enableBlockRightResize={enableBlockRightResize}
|
||||||
>
|
enableBlockMove={enableBlockMove}
|
||||||
<div
|
enableAddBlock={enableAddBlock}
|
||||||
className={cn("relative h-full", {
|
ganttContainerRef={ganttContainerRef}
|
||||||
"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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import { Expand, Shrink } from "lucide-react";
|
import { Expand, Shrink } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useChart } from "../hooks";
|
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
// types
|
// types
|
||||||
import { IGanttBlock, TGanttViews } from "../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 = {
|
type Props = {
|
||||||
blocks: IGanttBlock[] | null;
|
blocks: IGanttBlock[] | null;
|
||||||
@ -16,10 +19,10 @@ type Props = {
|
|||||||
toggleFullScreenMode: () => void;
|
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;
|
const { blocks, fullScreenMode, handleChartView, handleToday, loaderTitle, title, toggleFullScreenMode } = props;
|
||||||
// chart hook
|
// chart hook
|
||||||
const { currentView, allViews } = useChart();
|
const { currentView } = useGanttChart();
|
||||||
|
|
||||||
return (
|
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">
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{allViews?.map((chartView: any) => (
|
{VIEWS_LIST.map((chartView: any) => (
|
||||||
<div
|
<div
|
||||||
key={chartView?.key}
|
key={chartView?.key}
|
||||||
className={cn("cursor-pointer rounded-sm p-1 px-2 text-xs", {
|
className={cn("cursor-pointer rounded-sm p-1 px-2 text-xs", {
|
||||||
@ -56,4 +59,4 @@ export const GanttChartHeader: React.FC<Props> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
// hooks
|
||||||
|
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||||
// components
|
// components
|
||||||
import {
|
import {
|
||||||
BiWeekChartView,
|
BiWeekChartView,
|
||||||
@ -12,7 +16,6 @@ import {
|
|||||||
TGanttViews,
|
TGanttViews,
|
||||||
WeekChartView,
|
WeekChartView,
|
||||||
YearChartView,
|
YearChartView,
|
||||||
useChart,
|
|
||||||
} from "components/gantt-chart";
|
} from "components/gantt-chart";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
@ -36,7 +39,7 @@ type Props = {
|
|||||||
quickAdd?: React.JSX.Element | undefined;
|
quickAdd?: React.JSX.Element | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GanttChartMainContent: React.FC<Props> = (props) => {
|
export const GanttChartMainContent: React.FC<Props> = observer((props) => {
|
||||||
const {
|
const {
|
||||||
blocks,
|
blocks,
|
||||||
blockToRender,
|
blockToRender,
|
||||||
@ -55,13 +58,15 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
|
|||||||
updateCurrentViewRenderPayload,
|
updateCurrentViewRenderPayload,
|
||||||
quickAdd,
|
quickAdd,
|
||||||
} = props;
|
} = props;
|
||||||
|
// refs
|
||||||
|
const ganttContainerRef = useRef<HTMLDivElement>(null);
|
||||||
// chart hook
|
// chart hook
|
||||||
const { currentView, currentViewData, updateScrollLeft } = useChart();
|
const { currentView, currentViewData } = useGanttChart();
|
||||||
// handling scroll functionality
|
// handling scroll functionality
|
||||||
const onScroll = (e: React.UIEvent<HTMLDivElement, UIEvent>) => {
|
const onScroll = (e: React.UIEvent<HTMLDivElement, UIEvent>) => {
|
||||||
const { clientWidth, scrollLeft, scrollWidth } = e.currentTarget;
|
const { clientWidth, scrollLeft, scrollWidth } = e.currentTarget;
|
||||||
|
|
||||||
updateScrollLeft(scrollLeft);
|
// updateScrollLeft(scrollLeft);
|
||||||
|
|
||||||
const approxRangeLeft = scrollLeft >= clientWidth + 1000 ? 1000 : scrollLeft - clientWidth;
|
const approxRangeLeft = scrollLeft >= clientWidth + 1000 ? 1000 : scrollLeft - clientWidth;
|
||||||
const approxRangeRight = scrollWidth - (scrollLeft + clientWidth);
|
const approxRangeRight = scrollWidth - (scrollLeft + clientWidth);
|
||||||
@ -95,6 +100,7 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
|
|||||||
"mb-8": bottomSpacing,
|
"mb-8": bottomSpacing,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
|
ref={ganttContainerRef}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
>
|
>
|
||||||
<GanttChartSidebar
|
<GanttChartSidebar
|
||||||
@ -105,7 +111,7 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
|
|||||||
title={title}
|
title={title}
|
||||||
quickAdd={quickAdd}
|
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 />
|
<ActiveChartView />
|
||||||
{currentViewData && (
|
{currentViewData && (
|
||||||
<GanttChartBlocksList
|
<GanttChartBlocksList
|
||||||
@ -117,10 +123,11 @@ export const GanttChartMainContent: React.FC<Props> = (props) => {
|
|||||||
enableBlockRightResize={enableBlockRightResize}
|
enableBlockRightResize={enableBlockRightResize}
|
||||||
enableBlockMove={enableBlockMove}
|
enableBlockMove={enableBlockMove}
|
||||||
enableAddBlock={enableAddBlock}
|
enableAddBlock={enableAddBlock}
|
||||||
|
ganttContainerRef={ganttContainerRef}
|
||||||
showAllBlocks={showAllBlocks}
|
showAllBlocks={showAllBlocks}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import { FC, useEffect, useState } from "react";
|
import { FC, useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
// hooks
|
||||||
|
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||||
// components
|
// components
|
||||||
import { GanttChartHeader, useChart, GanttChartMainContent } from "components/gantt-chart";
|
import { GanttChartHeader, GanttChartMainContent } from "components/gantt-chart";
|
||||||
// views
|
// views
|
||||||
import {
|
import {
|
||||||
generateMonthChart,
|
generateMonthChart,
|
||||||
@ -34,7 +37,7 @@ type ChartViewRootProps = {
|
|||||||
quickAdd?: React.JSX.Element | undefined;
|
quickAdd?: React.JSX.Element | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
|
export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
|
||||||
const {
|
const {
|
||||||
border,
|
border,
|
||||||
title,
|
title,
|
||||||
@ -57,7 +60,8 @@ export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
|
|||||||
const [fullScreenMode, setFullScreenMode] = useState(false);
|
const [fullScreenMode, setFullScreenMode] = useState(false);
|
||||||
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null);
|
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null);
|
||||||
// hooks
|
// hooks
|
||||||
const { currentView, currentViewData, renderView, dispatch } = useChart();
|
const { currentView, currentViewData, renderView, updateCurrentView, updateCurrentViewData, updateRenderView } =
|
||||||
|
useGanttChart();
|
||||||
|
|
||||||
// rendering the block structure
|
// rendering the block structure
|
||||||
const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) =>
|
const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) =>
|
||||||
@ -87,36 +91,20 @@ export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
|
|||||||
|
|
||||||
// updating the prevData, currentData and nextData
|
// updating the prevData, currentData and nextData
|
||||||
if (currentRender.payload.length > 0) {
|
if (currentRender.payload.length > 0) {
|
||||||
|
updateCurrentViewData(currentRender.state);
|
||||||
|
|
||||||
if (side === "left") {
|
if (side === "left") {
|
||||||
dispatch({
|
updateCurrentView(selectedCurrentView);
|
||||||
type: "PARTIAL_UPDATE",
|
updateRenderView([...currentRender.payload, ...renderView]);
|
||||||
payload: {
|
|
||||||
currentView: selectedCurrentView,
|
|
||||||
currentViewData: currentRender.state,
|
|
||||||
renderView: [...currentRender.payload, ...renderView],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
updatingCurrentLeftScrollPosition(currentRender.scrollWidth);
|
updatingCurrentLeftScrollPosition(currentRender.scrollWidth);
|
||||||
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
|
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
|
||||||
} else if (side === "right") {
|
} else if (side === "right") {
|
||||||
dispatch({
|
updateCurrentView(view);
|
||||||
type: "PARTIAL_UPDATE",
|
updateRenderView([...renderView, ...currentRender.payload]);
|
||||||
payload: {
|
|
||||||
currentView: view,
|
|
||||||
currentViewData: currentRender.state,
|
|
||||||
renderView: [...renderView, ...currentRender.payload],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
|
setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth);
|
||||||
} else {
|
} else {
|
||||||
dispatch({
|
updateCurrentView(view);
|
||||||
type: "PARTIAL_UPDATE",
|
updateRenderView(currentRender.payload);
|
||||||
payload: {
|
|
||||||
currentView: view,
|
|
||||||
currentViewData: currentRender.state,
|
|
||||||
renderView: [...currentRender.payload],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setItemsContainerWidth(currentRender.scrollWidth);
|
setItemsContainerWidth(currentRender.scrollWidth);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
handleScrollToCurrentSelectedDate(currentRender.state, currentRender.state.data.currentDate);
|
handleScrollToCurrentSelectedDate(currentRender.state, currentRender.state.data.currentDate);
|
||||||
@ -206,4 +194,4 @@ export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
// context
|
import { observer } from "mobx-react";
|
||||||
import { useChart } from "components/gantt-chart";
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
const { currentView, currentViewData, renderView } = useGanttChart();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -50,4 +51,4 @@ export const BiWeekChartView: FC<any> = () => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
// context
|
import { observer } from "mobx-react";
|
||||||
import { useChart } from "../../hooks";
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
const { currentView, currentViewData, renderView } = useGanttChart();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -50,4 +51,4 @@ export const DayChartView: FC<any> = () => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
// context
|
import { observer } from "mobx-react";
|
||||||
import { useChart } from "components/gantt-chart";
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
const { currentView, currentViewData, renderView } = useGanttChart();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -50,4 +51,4 @@ export const HourChartView: FC<any> = () => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useChart } from "components/gantt-chart";
|
import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
// types
|
// types
|
||||||
@ -8,9 +9,9 @@ import { IMonthBlock } from "../../views";
|
|||||||
// constants
|
// constants
|
||||||
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "components/gantt-chart/constants";
|
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "components/gantt-chart/constants";
|
||||||
|
|
||||||
export const MonthChartView: FC<any> = () => {
|
export const MonthChartView: FC<any> = observer(() => {
|
||||||
// chart hook
|
// chart hook
|
||||||
const { currentViewData, renderView } = useChart();
|
const { currentViewData, renderView } = useGanttChart();
|
||||||
const monthBlocks: IMonthBlock[] = renderView;
|
const monthBlocks: IMonthBlock[] = renderView;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -71,4 +72,4 @@ export const MonthChartView: FC<any> = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
// context
|
import { observer } from "mobx-react";
|
||||||
import { useChart } from "../../hooks";
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
const { currentView, currentViewData, renderView } = useGanttChart();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -46,4 +47,4 @@ export const QuarterChartView: FC<any> = () => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
// context
|
import { observer } from "mobx-react";
|
||||||
import { useChart } from "../../hooks";
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
const { currentView, currentViewData, renderView } = useGanttChart();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -50,4 +51,4 @@ export const WeekChartView: FC<any> = () => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
// context
|
import { observer } from "mobx-react";
|
||||||
import { useChart } from "../../hooks";
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
const { currentView, currentViewData, renderView } = useGanttChart();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -46,4 +47,4 @@ export const YearChartView: FC<any> = () => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,57 +1,19 @@
|
|||||||
import React, { createContext, useState } from "react";
|
import { createContext } from "react";
|
||||||
// types
|
// mobx store
|
||||||
import { ChartContextData, ChartContextActionPayload, ChartContextReducer } from "../types";
|
import { GanttStore } from "store/issue/issue_gantt_view.store";
|
||||||
// data
|
|
||||||
import { allViewsWithData, currentViewDataWithView } from "../data";
|
|
||||||
|
|
||||||
export const ChartContext = createContext<ChartContextReducer | undefined>(undefined);
|
let ganttViewStore = new GanttStore();
|
||||||
|
|
||||||
const chartReducer = (state: ChartContextData, action: ChartContextActionPayload): ChartContextData => {
|
export const GanttStoreContext = createContext<GanttStore>(ganttViewStore);
|
||||||
switch (action.type) {
|
|
||||||
case "CURRENT_VIEW":
|
const initializeStore = () => {
|
||||||
return { ...state, currentView: action.payload };
|
const _ganttStore = ganttViewStore ?? new GanttStore();
|
||||||
case "CURRENT_VIEW_DATA":
|
if (typeof window === "undefined") return _ganttStore;
|
||||||
return { ...state, currentViewData: action.payload };
|
if (!ganttViewStore) ganttViewStore = _ganttStore;
|
||||||
case "RENDER_VIEW":
|
return _ganttStore;
|
||||||
return { ...state, currentViewData: action.payload };
|
|
||||||
case "PARTIAL_UPDATE":
|
|
||||||
return { ...state, ...action.payload };
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialView = "month";
|
export const GanttStoreProvider = ({ children }: any) => {
|
||||||
|
const store = initializeStore();
|
||||||
export const ChartContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
return <GanttStoreContext.Provider value={store}>{children}</GanttStoreContext.Provider>;
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
// types
|
// types
|
||||||
import { WeekMonthDataType, ChartDataType } from "../types";
|
import { WeekMonthDataType, ChartDataType, TGanttViews } from "../types";
|
||||||
|
|
||||||
// constants
|
// constants
|
||||||
export const weeks: WeekMonthDataType[] = [
|
export const weeks: WeekMonthDataType[] = [
|
||||||
@ -53,7 +53,7 @@ export const datePreview = (date: Date, includeTime: boolean = false) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// context data
|
// context data
|
||||||
export const allViewsWithData: ChartDataType[] = [
|
export const VIEWS_LIST: ChartDataType[] = [
|
||||||
// {
|
// {
|
||||||
// key: "hours",
|
// key: "hours",
|
||||||
// title: "Hours",
|
// title: "Hours",
|
||||||
@ -133,7 +133,5 @@ export const allViewsWithData: ChartDataType[] = [
|
|||||||
// },
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const currentViewDataWithView = (view: string = "month") => {
|
export const currentViewDataWithView = (view: TGanttViews = "month") =>
|
||||||
const currentView: ChartDataType | undefined = allViewsWithData.find((_viewData) => _viewData.key === view);
|
VIEWS_LIST.find((_viewData) => _viewData.key === view);
|
||||||
return currentView;
|
|
||||||
};
|
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { addDays } from "date-fns";
|
import { addDays } from "date-fns";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
// hooks
|
|
||||||
import { useChart } from "../hooks";
|
|
||||||
// ui
|
// ui
|
||||||
import { Tooltip } from "@plane/ui";
|
import { Tooltip } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-time.helper";
|
import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||||
// types
|
// types
|
||||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||||
|
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
block: IGanttBlock;
|
block: IGanttBlock;
|
||||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
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;
|
const { block, blockUpdateHandler } = props;
|
||||||
// states
|
// states
|
||||||
const [isButtonVisible, setIsButtonVisible] = useState(false);
|
const [isButtonVisible, setIsButtonVisible] = useState(false);
|
||||||
@ -24,7 +24,7 @@ export const ChartAddBlock: React.FC<Props> = (props) => {
|
|||||||
// refs
|
// refs
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
// chart hook
|
// chart hook
|
||||||
const { currentViewData } = useChart();
|
const { currentViewData } = useGanttChart();
|
||||||
|
|
||||||
const handleButtonClick = () => {
|
const handleButtonClick = () => {
|
||||||
if (!currentViewData) return;
|
if (!currentViewData) return;
|
||||||
@ -88,4 +88,4 @@ export const ChartAddBlock: React.FC<Props> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 React, { useEffect, useRef, useState } from "react";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { IGanttBlock, useChart } from "components/gantt-chart";
|
import { IGanttBlock } from "components/gantt-chart";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
// constants
|
// constants
|
||||||
import { SIDEBAR_WIDTH } from "../constants";
|
import { SIDEBAR_WIDTH } from "../constants";
|
||||||
|
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
block: IGanttBlock;
|
block: IGanttBlock;
|
||||||
@ -14,19 +16,29 @@ type Props = {
|
|||||||
enableBlockLeftResize: boolean;
|
enableBlockLeftResize: boolean;
|
||||||
enableBlockRightResize: boolean;
|
enableBlockRightResize: boolean;
|
||||||
enableBlockMove: boolean;
|
enableBlockMove: boolean;
|
||||||
|
ganttContainerRef: React.RefObject<HTMLDivElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChartDraggable: React.FC<Props> = (props) => {
|
export const ChartDraggable: React.FC<Props> = observer((props) => {
|
||||||
const { block, blockToRender, handleBlock, enableBlockLeftResize, enableBlockRightResize, enableBlockMove } = props;
|
const {
|
||||||
|
block,
|
||||||
|
blockToRender,
|
||||||
|
handleBlock,
|
||||||
|
enableBlockLeftResize,
|
||||||
|
enableBlockRightResize,
|
||||||
|
enableBlockMove,
|
||||||
|
ganttContainerRef,
|
||||||
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [isLeftResizing, setIsLeftResizing] = useState(false);
|
const [isLeftResizing, setIsLeftResizing] = useState(false);
|
||||||
const [isRightResizing, setIsRightResizing] = useState(false);
|
const [isRightResizing, setIsRightResizing] = useState(false);
|
||||||
const [isMoving, setIsMoving] = useState(false);
|
const [isMoving, setIsMoving] = useState(false);
|
||||||
const [isHidden, setIsHidden] = useState(true);
|
const [isHidden, setIsHidden] = useState(true);
|
||||||
|
const [scrollLeft, setScrollLeft] = useState(0);
|
||||||
// refs
|
// refs
|
||||||
const resizableRef = useRef<HTMLDivElement>(null);
|
const resizableRef = useRef<HTMLDivElement>(null);
|
||||||
// chart hook
|
// chart hook
|
||||||
const { currentViewData, scrollLeft } = useChart();
|
const { currentViewData } = useGanttChart();
|
||||||
// check if cursor reaches either end while resizing/dragging
|
// check if cursor reaches either end while resizing/dragging
|
||||||
const checkScrollEnd = (e: MouseEvent): number => {
|
const checkScrollEnd = (e: MouseEvent): number => {
|
||||||
const SCROLL_THRESHOLD = 70;
|
const SCROLL_THRESHOLD = 70;
|
||||||
@ -212,6 +224,17 @@ export const ChartDraggable: React.FC<Props> = (props) => {
|
|||||||
block.position?.width &&
|
block.position?.width &&
|
||||||
scrollLeft > block.position.marginLeft + 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(() => {
|
useEffect(() => {
|
||||||
const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement;
|
const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement;
|
||||||
const resizableBlock = resizableRef.current;
|
const resizableBlock = resizableRef.current;
|
||||||
@ -234,7 +257,7 @@ export const ChartDraggable: React.FC<Props> = (props) => {
|
|||||||
return () => {
|
return () => {
|
||||||
observer.unobserve(resizableBlock);
|
observer.unobserve(resizableBlock);
|
||||||
};
|
};
|
||||||
}, [block.data.name]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -312,4 +335,4 @@ export const ChartDraggable: React.FC<Props> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,3 +1,2 @@
|
|||||||
export * from "./add-block";
|
export * from "./add-block";
|
||||||
export * from "./block-structure";
|
|
||||||
export * from "./draggable";
|
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 "./helpers";
|
||||||
export * from "./hooks";
|
export * from "./hooks";
|
||||||
export * from "./root";
|
export * from "./root";
|
||||||
export * from "./types";
|
|
||||||
export * from "./sidebar";
|
export * from "./sidebar";
|
||||||
|
export * from "./types";
|
||||||
|
@ -2,7 +2,7 @@ import { FC } from "react";
|
|||||||
// components
|
// components
|
||||||
import { ChartViewRoot, IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
|
import { ChartViewRoot, IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
|
||||||
// context
|
// context
|
||||||
import { ChartContextProvider } from "./contexts";
|
import { GanttStoreProvider } from "components/gantt-chart/contexts";
|
||||||
|
|
||||||
type GanttChartRootProps = {
|
type GanttChartRootProps = {
|
||||||
border?: boolean;
|
border?: boolean;
|
||||||
@ -42,7 +42,7 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContextProvider>
|
<GanttStoreProvider>
|
||||||
<ChartViewRoot
|
<ChartViewRoot
|
||||||
border={border}
|
border={border}
|
||||||
title={title}
|
title={title}
|
||||||
@ -60,6 +60,6 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
|
|||||||
showAllBlocks={showAllBlocks}
|
showAllBlocks={showAllBlocks}
|
||||||
quickAdd={quickAdd}
|
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 { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
|
||||||
import { MoreVertical } from "lucide-react";
|
|
||||||
// hooks
|
|
||||||
import { useChart } from "components/gantt-chart/hooks";
|
|
||||||
// ui
|
// ui
|
||||||
import { Loader } from "@plane/ui";
|
import { Loader } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { IssueGanttSidebarBlock } from "components/issues";
|
import { IssuesSidebarBlock } from "./issues/block";
|
||||||
// helpers
|
|
||||||
import { findTotalDaysInRange } from "helpers/date-time.helper";
|
|
||||||
// types
|
// types
|
||||||
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types";
|
import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types";
|
||||||
// constants
|
|
||||||
import { BLOCK_HEIGHT } from "../constants";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
@ -23,18 +16,6 @@ type Props = {
|
|||||||
|
|
||||||
export const ProjectViewGanttSidebar: React.FC<Props> = (props) => {
|
export const ProjectViewGanttSidebar: React.FC<Props> = (props) => {
|
||||||
const { blockUpdateHandler, blocks, enableReorder } = 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) => {
|
const handleOrderChange = (result: DropResult) => {
|
||||||
if (!blocks) return;
|
if (!blocks) return;
|
||||||
@ -89,59 +70,23 @@ export const ProjectViewGanttSidebar: React.FC<Props> = (props) => {
|
|||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
{blocks ? (
|
{blocks ? (
|
||||||
blocks.map((block, index) => {
|
blocks.map((block, index) => (
|
||||||
const duration = findTotalDaysInRange(block.start_date, block.target_date);
|
<Draggable
|
||||||
|
key={`sidebar-block-${block.id}`}
|
||||||
return (
|
draggableId={`sidebar-block-${block.id}`}
|
||||||
<Draggable
|
index={index}
|
||||||
key={`sidebar-block-${block.id}`}
|
isDragDisabled={!enableReorder}
|
||||||
draggableId={`sidebar-block-${block.id}`}
|
>
|
||||||
index={index}
|
{(provided, snapshot) => (
|
||||||
isDragDisabled={!enableReorder}
|
<IssuesSidebarBlock
|
||||||
>
|
block={block}
|
||||||
{(provided, snapshot) => (
|
enableReorder={enableReorder}
|
||||||
<div
|
provided={provided}
|
||||||
className={`${snapshot.isDragging ? "rounded bg-custom-background-80" : ""}`}
|
snapshot={snapshot}
|
||||||
style={{
|
/>
|
||||||
height: `${BLOCK_HEIGHT}px`,
|
)}
|
||||||
}}
|
</Draggable>
|
||||||
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>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
) : (
|
||||||
<Loader className="space-y-3 pr-2">
|
<Loader className="space-y-3 pr-2">
|
||||||
<Loader.Item height="34px" />
|
<Loader.Item height="34px" />
|
||||||
|
@ -1,10 +1,3 @@
|
|||||||
// context types
|
|
||||||
export type allViewsType = {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
data: Object | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IGanttBlock {
|
export interface IGanttBlock {
|
||||||
data: any;
|
data: any;
|
||||||
id: string;
|
id: string;
|
||||||
@ -29,34 +22,6 @@ export interface IBlockUpdateData {
|
|||||||
|
|
||||||
export type TGanttViews = "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year";
|
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
|
// chart render types
|
||||||
export interface WeekMonthDataType {
|
export interface WeekMonthDataType {
|
||||||
key: number;
|
key: number;
|
||||||
|
@ -149,7 +149,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
|||||||
onClose={() => setAnalyticsModal(false)}
|
onClose={() => setAnalyticsModal(false)}
|
||||||
cycleDetails={cycleDetails ?? undefined}
|
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 justify-between border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<SidebarHamburgerToggle />
|
<SidebarHamburgerToggle />
|
||||||
@ -175,7 +175,12 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</span>
|
</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>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -282,5 +287,3 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@ -107,7 +107,7 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateUpdateWorkspaceViewModal isOpen={createViewModal} onClose={() => setCreateViewModal(false)} />
|
<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">
|
<div className="relative flex gap-2">
|
||||||
<SidebarHamburgerToggle />
|
<SidebarHamburgerToggle />
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
|
@ -152,7 +152,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
|||||||
onClose={() => setAnalyticsModal(false)}
|
onClose={() => setAnalyticsModal(false)}
|
||||||
moduleDetails={moduleDetails ?? undefined}
|
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 justify-between border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<SidebarHamburgerToggle />
|
<SidebarHamburgerToggle />
|
||||||
@ -178,7 +178,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</span>
|
</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>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -249,7 +254,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
|||||||
|
|
||||||
{canUserCreateIssue && (
|
{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
|
Analytics
|
||||||
</Button>
|
</Button>
|
||||||
<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"
|
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
>
|
>
|
||||||
<ArrowRight className={`h-4 w-4 duration-300 hidden md:block ${isSidebarCollapsed ? "-rotate-180" : ""}`} />
|
<ArrowRight
|
||||||
<PanelRight className={cn("w-4 h-4 block md:hidden", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -109,7 +109,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
|||||||
onClose={() => setAnalyticsModal(false)}
|
onClose={() => setAnalyticsModal(false)}
|
||||||
projectDetails={currentProjectDetails ?? undefined}
|
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 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">
|
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||||
<SidebarHamburgerToggle />
|
<SidebarHamburgerToggle />
|
||||||
|
@ -108,7 +108,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
|||||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<SidebarHamburgerToggle />
|
<SidebarHamburgerToggle />
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
|
@ -196,9 +196,9 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = observer(
|
|||||||
const updateDraftIssue = async (payload: Partial<TIssue>) => {
|
const updateDraftIssue = async (payload: Partial<TIssue>) => {
|
||||||
await draftIssues
|
await draftIssues
|
||||||
.updateIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload)
|
.updateIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload)
|
||||||
.then((res) => {
|
.then(() => {
|
||||||
if (isUpdatingSingleIssue) {
|
if (isUpdatingSingleIssue) {
|
||||||
mutate<TIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
|
mutate<TIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...payload } as TIssue), false);
|
||||||
} else {
|
} else {
|
||||||
if (payload.parent_id) mutate(SUB_ISSUES(payload.parent_id.toString()));
|
if (payload.parent_id) mutate(SUB_ISSUES(payload.parent_id.toString()));
|
||||||
}
|
}
|
||||||
|
@ -53,7 +53,8 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
|
|||||||
control,
|
control,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
reset,
|
reset,
|
||||||
} = useForm<Partial<TIssueComment>>({ defaultValues: { comment_html: "<p></p>" } });
|
watch,
|
||||||
|
} = useForm<Partial<TIssueComment>>({ defaultValues: { comment_html: "" } });
|
||||||
|
|
||||||
const onSubmit = async (formData: Partial<TIssueComment>) => {
|
const onSubmit = async (formData: Partial<TIssueComment>) => {
|
||||||
await activityOperations.createComment(formData).finally(() => {
|
await activityOperations.createComment(formData).finally(() => {
|
||||||
@ -88,7 +89,7 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
|
|||||||
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
|
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
|
||||||
restoreFile={fileService.getRestoreImageFunction(workspaceId)}
|
restoreFile={fileService.getRestoreImageFunction(workspaceId)}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={!value ? "<p></p>" : value}
|
value={value ?? ""}
|
||||||
customClassName="p-2"
|
customClassName="p-2"
|
||||||
editorContentCustomClassNames="min-h-[35px]"
|
editorContentCustomClassNames="min-h-[35px]"
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
@ -104,7 +105,7 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
|
|||||||
}
|
}
|
||||||
submitButton={
|
submitButton={
|
||||||
<Button
|
<Button
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || watch("comment_html") === ""}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
className="!px-2.5 !py-1.5 !text-xs"
|
className="!px-2.5 !py-1.5 !text-xs"
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { FC, useState } from "react";
|
import { FC, useState } from "react";
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
import { useIssueDetail } from "hooks/store";
|
import { useIssueDetail, useMember } from "hooks/store";
|
||||||
// ui
|
// ui
|
||||||
import { ExternalLinkIcon, Tooltip } from "@plane/ui";
|
import { ExternalLinkIcon, Tooltip } from "@plane/ui";
|
||||||
// icons
|
// icons
|
||||||
@ -26,6 +26,7 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
|
|||||||
toggleIssueLinkModal: toggleIssueLinkModalStore,
|
toggleIssueLinkModal: toggleIssueLinkModalStore,
|
||||||
link: { getLinkById },
|
link: { getLinkById },
|
||||||
} = useIssueDetail();
|
} = useIssueDetail();
|
||||||
|
const { getUserDetails } = useMember();
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
// state
|
// state
|
||||||
@ -38,6 +39,8 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
|
|||||||
const linkDetail = getLinkById(linkId);
|
const linkDetail = getLinkById(linkId);
|
||||||
if (!linkDetail) return <></>;
|
if (!linkDetail) return <></>;
|
||||||
|
|
||||||
|
const createdByDetails = getUserDetails(linkDetail.created_by_id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={linkId}>
|
<div key={linkId}>
|
||||||
<IssueLinkCreateUpdateModal
|
<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">
|
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
|
||||||
Added {calculateTimeAgo(linkDetail.created_at)}
|
Added {calculateTimeAgo(linkDetail.created_at)}
|
||||||
<br />
|
<br />
|
||||||
by{" "}
|
{createdByDetails && (
|
||||||
{linkDetail.created_by_detail.is_bot
|
<>
|
||||||
? linkDetail.created_by_detail.first_name + " Bot"
|
by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
|
||||||
: linkDetail.created_by_detail.display_name}
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -96,7 +96,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
|||||||
showToast: boolean = true
|
showToast: boolean = true
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await updateIssue(workspaceSlug, projectId, issueId, data);
|
await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
title: "Issue updated successfully",
|
title: "Issue updated successfully",
|
||||||
@ -106,7 +106,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
|||||||
}
|
}
|
||||||
captureIssueEvent({
|
captureIssueEvent({
|
||||||
eventName: ISSUE_UPDATED,
|
eventName: ISSUE_UPDATED,
|
||||||
payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
|
payload: { ...data, issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||||
updates: {
|
updates: {
|
||||||
changed_property: Object.keys(data).join(","),
|
changed_property: Object.keys(data).join(","),
|
||||||
change_details: Object.values(data).join(","),
|
change_details: Object.values(data).join(","),
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { differenceInCalendarDays } from "date-fns";
|
|
||||||
import {
|
import {
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
Signal,
|
Signal,
|
||||||
@ -15,7 +14,7 @@ import {
|
|||||||
CalendarDays,
|
CalendarDays,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useEstimate, useIssueDetail, useProject, useUser } from "hooks/store";
|
import { useEstimate, useIssueDetail, useProject, useProjectState, useUser } from "hooks/store";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
import {
|
import {
|
||||||
@ -41,6 +40,7 @@ import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, UserGroupIcon }
|
|||||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||||
import { copyTextToClipboard } from "helpers/string.helper";
|
import { copyTextToClipboard } from "helpers/string.helper";
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
|
import { shouldHighlightIssueDueDate } from "helpers/issue.helper";
|
||||||
// types
|
// types
|
||||||
import type { TIssueOperations } from "./root";
|
import type { TIssueOperations } from "./root";
|
||||||
|
|
||||||
@ -65,6 +65,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
const {
|
const {
|
||||||
issue: { getIssueById },
|
issue: { getIssueById },
|
||||||
} = useIssueDetail();
|
} = useIssueDetail();
|
||||||
|
const { getStateById } = useProjectState();
|
||||||
// states
|
// states
|
||||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
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 projectDetails = issue ? getProjectById(issue.project_id) : null;
|
||||||
|
const stateDetails = getStateById(issue.state_id);
|
||||||
|
|
||||||
const minDate = issue.start_date ? new Date(issue.start_date) : null;
|
const minDate = issue.start_date ? new Date(issue.start_date) : null;
|
||||||
minDate?.setDate(minDate.getDate());
|
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;
|
const maxDate = issue.target_date ? new Date(issue.target_date) : null;
|
||||||
maxDate?.setDate(maxDate.getDate());
|
maxDate?.setDate(maxDate.getDate());
|
||||||
|
|
||||||
const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{workspaceSlug && projectId && issue && (
|
{workspaceSlug && projectId && issue && (
|
||||||
@ -242,7 +242,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
buttonContainerClassName="w-full text-left"
|
buttonContainerClassName="w-full text-left"
|
||||||
buttonClassName={cn("text-sm", {
|
buttonClassName={cn("text-sm", {
|
||||||
"text-custom-text-400": !issue.target_date,
|
"text-custom-text-400": !issue.target_date,
|
||||||
"text-red-500": targetDateDistance <= 0,
|
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
|
||||||
})}
|
})}
|
||||||
hideIcon
|
hideIcon
|
||||||
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
|
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 { Bell, BellOff } from "lucide-react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { FC, useState } from "react";
|
||||||
// UI
|
// UI
|
||||||
import { Button, Loader } from "@plane/ui";
|
import { Button, Loader } from "@plane/ui";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail } from "hooks/store";
|
import { useIssueDetail } from "hooks/store";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
|
import isNil from "lodash/isNil";
|
||||||
|
|
||||||
export type TIssueSubscription = {
|
export type TIssueSubscription = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
@ -25,17 +26,17 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
|
|||||||
// state
|
// state
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const subscription = getSubscriptionByIssueId(issueId);
|
const isSubscribed = getSubscriptionByIssueId(issueId);
|
||||||
|
|
||||||
const handleSubscription = async () => {
|
const handleSubscription = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (subscription?.subscribed) await removeSubscription(workspaceSlug, projectId, issueId);
|
if (isSubscribed) await removeSubscription(workspaceSlug, projectId, issueId);
|
||||||
else await createSubscription(workspaceSlug, projectId, issueId);
|
else await createSubscription(workspaceSlug, projectId, issueId);
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "success",
|
type: "success",
|
||||||
title: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`,
|
title: `Issue ${isSubscribed ? `unsubscribed` : `subscribed`} successfully.!`,
|
||||||
message: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`,
|
message: `Issue ${isSubscribed ? `unsubscribed` : `subscribed`} successfully.!`,
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -48,42 +49,32 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!subscription)
|
if (isNil(isSubscribed))
|
||||||
return (
|
return (
|
||||||
<Loader>
|
<Loader>
|
||||||
<Loader.Item width="92px" height="27px" />
|
<Loader.Item width="106px" height="28px" />
|
||||||
</Loader>
|
</Loader>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
{subscription ? (
|
<Button
|
||||||
<div>
|
size="sm"
|
||||||
<Button
|
prependIcon={isSubscribed ? <BellOff /> : <Bell className="h-3 w-3" />}
|
||||||
size="sm"
|
variant="outline-primary"
|
||||||
prependIcon={subscription?.subscribed ? <BellOff /> : <Bell className="h-3 w-3" />}
|
className="hover:!bg-custom-primary-100/20"
|
||||||
variant="outline-primary"
|
onClick={handleSubscription}
|
||||||
className="hover:!bg-custom-primary-100/20"
|
>
|
||||||
onClick={handleSubscription}
|
{loading ? (
|
||||||
>
|
<span>
|
||||||
{loading ? (
|
<span className="hidden sm:block">Loading</span>...
|
||||||
<span>
|
</span>
|
||||||
<span className="hidden sm:block">Loading...</span>
|
) : isSubscribed ? (
|
||||||
</span>
|
<div className="hidden sm:block">Unsubscribe</div>
|
||||||
) : subscription?.subscribed ? (
|
) : (
|
||||||
<div className="hidden sm:block">Unsubscribe</div>
|
<div className="hidden sm:block">Subscribe</div>
|
||||||
) : (
|
)}
|
||||||
<div className="hidden sm:block">Subscribe</div>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Loader>
|
|
||||||
<Loader.Item height="28px" width="106px" />
|
|
||||||
</Loader>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -73,42 +73,44 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
|
|||||||
<>
|
<>
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||||
<CalendarHeader issuesFilterStore={issuesFilterStore} viewId={viewId} />
|
<CalendarHeader issuesFilterStore={issuesFilterStore} viewId={viewId} />
|
||||||
<CalendarWeekHeader isLoading={!issues} showWeekends={showWeekends} />
|
<div className="flex h-full w-full vertical-scrollbar scrollbar-lg flex-col">
|
||||||
<div className="h-full w-full overflow-y-auto vertical-scrollbar scrollbar-lg">
|
<CalendarWeekHeader isLoading={!issues} showWeekends={showWeekends} />
|
||||||
{layout === "month" && (
|
<div className="h-full w-full">
|
||||||
<div className="grid h-full w-full grid-cols-1 divide-y-[0.5px] divide-custom-border-200">
|
{layout === "month" && (
|
||||||
{allWeeksOfActiveMonth &&
|
<div className="grid h-full w-full grid-cols-1 divide-y-[0.5px] divide-custom-border-200">
|
||||||
Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => (
|
{allWeeksOfActiveMonth &&
|
||||||
<CalendarWeekDays
|
Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => (
|
||||||
issuesFilterStore={issuesFilterStore}
|
<CalendarWeekDays
|
||||||
key={weekIndex}
|
issuesFilterStore={issuesFilterStore}
|
||||||
week={week}
|
key={weekIndex}
|
||||||
issues={issues}
|
week={week}
|
||||||
groupedIssueIds={groupedIssueIds}
|
issues={issues}
|
||||||
enableQuickIssueCreate
|
groupedIssueIds={groupedIssueIds}
|
||||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
enableQuickIssueCreate
|
||||||
quickActions={quickActions}
|
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||||
quickAddCallback={quickAddCallback}
|
quickActions={quickActions}
|
||||||
viewId={viewId}
|
quickAddCallback={quickAddCallback}
|
||||||
readOnly={readOnly}
|
viewId={viewId}
|
||||||
/>
|
readOnly={readOnly}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
{layout === "week" && (
|
)}
|
||||||
<CalendarWeekDays
|
{layout === "week" && (
|
||||||
issuesFilterStore={issuesFilterStore}
|
<CalendarWeekDays
|
||||||
week={issueCalendarView.allDaysOfActiveWeek}
|
issuesFilterStore={issuesFilterStore}
|
||||||
issues={issues}
|
week={issueCalendarView.allDaysOfActiveWeek}
|
||||||
groupedIssueIds={groupedIssueIds}
|
issues={issues}
|
||||||
enableQuickIssueCreate
|
groupedIssueIds={groupedIssueIds}
|
||||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
enableQuickIssueCreate
|
||||||
quickActions={quickActions}
|
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||||
quickAddCallback={quickAddCallback}
|
quickActions={quickActions}
|
||||||
viewId={viewId}
|
quickAddCallback={quickAddCallback}
|
||||||
readOnly={readOnly}
|
viewId={viewId}
|
||||||
/>
|
readOnly={readOnly}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</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
|
snapshot.isDraggingOver || date.date.getDay() === 0 || date.date.getDay() === 6
|
||||||
? "bg-custom-background-90"
|
? "bg-custom-background-90"
|
||||||
: "bg-custom-background-100"
|
: "bg-custom-background-100"
|
||||||
} ${calendarLayout === "month" ? "min-h-[9rem]" : ""}`}
|
} ${calendarLayout === "month" ? "min-h-[5rem]" : ""}`}
|
||||||
{...provided.droppableProps}
|
{...provided.droppableProps}
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
>
|
>
|
||||||
|
@ -13,7 +13,7 @@ export const CalendarWeekHeader: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
showWeekends ? "grid-cols-7" : "grid-cols-5"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
@ -5,12 +5,9 @@ import { observer } from "mobx-react-lite";
|
|||||||
import { useIssues, useUser } from "hooks/store";
|
import { useIssues, useUser } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
import { GanttQuickAddIssueForm, IssueGanttBlock } from "components/issues";
|
import { GanttQuickAddIssueForm, IssueGanttBlock } from "components/issues";
|
||||||
import {
|
import { GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "components/gantt-chart";
|
||||||
GanttChartRoot,
|
// helpers
|
||||||
IBlockUpdateData,
|
import { renderIssueBlocksStructure } from "helpers/issue.helper";
|
||||||
renderIssueBlocksStructure,
|
|
||||||
IssueGanttSidebar,
|
|
||||||
} from "components/gantt-chart";
|
|
||||||
// types
|
// types
|
||||||
import { TIssue, TUnGroupedIssues } from "@plane/types";
|
import { TIssue, TUnGroupedIssues } from "@plane/types";
|
||||||
import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle";
|
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] || [];
|
let _kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || [];
|
||||||
if (_kanbanFilters.includes(value)) _kanbanFilters = _kanbanFilters.filter((_value) => _value != value);
|
if (_kanbanFilters.includes(value)) _kanbanFilters = _kanbanFilters.filter((_value) => _value != value);
|
||||||
else _kanbanFilters.push(value);
|
else _kanbanFilters.push(value);
|
||||||
issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.KANBAN_FILTERS, {
|
issuesFilter.updateFilters(
|
||||||
[toggle]: _kanbanFilters,
|
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} />
|
<Plus height={14} width={14} strokeWidth={2} />
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
placement="bottom-end"
|
||||||
>
|
>
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { differenceInCalendarDays } from "date-fns";
|
|
||||||
import { Layers, Link, Paperclip } from "lucide-react";
|
import { Layers, Link, Paperclip } from "lucide-react";
|
||||||
import xor from "lodash/xor";
|
import xor from "lodash/xor";
|
||||||
// hooks
|
// hooks
|
||||||
import { useEventTracker, useEstimate, useLabel, useIssues } from "hooks/store";
|
import { useEventTracker, useEstimate, useLabel, useIssues, useProjectState } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
import { IssuePropertyLabels } from "../properties/labels";
|
import { IssuePropertyLabels } from "../properties/labels";
|
||||||
import { Tooltip } from "@plane/ui";
|
import { Tooltip } from "@plane/ui";
|
||||||
@ -21,6 +20,8 @@ import {
|
|||||||
} from "components/dropdowns";
|
} from "components/dropdowns";
|
||||||
// helpers
|
// helpers
|
||||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||||
|
import { shouldHighlightIssueDueDate } from "helpers/issue.helper";
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
// types
|
// types
|
||||||
import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types";
|
import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
@ -47,11 +48,14 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
|||||||
const {
|
const {
|
||||||
issues: { addIssueToCycle, removeIssueFromCycle },
|
issues: { addIssueToCycle, removeIssueFromCycle },
|
||||||
} = useIssues(EIssuesStoreType.CYCLE);
|
} = useIssues(EIssuesStoreType.CYCLE);
|
||||||
|
const { areEstimatesEnabledForCurrentProject } = useEstimate();
|
||||||
|
const { getStateById } = useProjectState();
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, cycleId, moduleId } = router.query;
|
const { workspaceSlug, cycleId, moduleId } = router.query;
|
||||||
const { areEstimatesEnabledForCurrentProject } = useEstimate();
|
|
||||||
const currentLayout = `${activeLayout} layout`;
|
const currentLayout = `${activeLayout} layout`;
|
||||||
|
// derived values
|
||||||
|
const stateDetails = getStateById(issue.state_id);
|
||||||
|
|
||||||
const issueOperations = useMemo(
|
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;
|
const maxDate = issue.target_date ? new Date(issue.target_date) : null;
|
||||||
maxDate?.setDate(maxDate.getDate());
|
maxDate?.setDate(maxDate.getDate());
|
||||||
|
|
||||||
const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{/* basic properties */}
|
{/* basic properties */}
|
||||||
@ -300,7 +302,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
|||||||
minDate={minDate ?? undefined}
|
minDate={minDate ?? undefined}
|
||||||
placeholder="Due date"
|
placeholder="Due date"
|
||||||
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
|
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"
|
clearIconClassName="!text-custom-text-100"
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
showTooltip
|
showTooltip
|
||||||
@ -378,12 +380,17 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
|||||||
<WithDisplayPropertiesHOC
|
<WithDisplayPropertiesHOC
|
||||||
displayProperties={displayProperties}
|
displayProperties={displayProperties}
|
||||||
displayPropertyKey="sub_issue_count"
|
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}`}>
|
<Tooltip tooltipHeading="Sub-issues" tooltipContent={`${issue.sub_issues_count}`}>
|
||||||
<div
|
<div
|
||||||
onClick={redirectToIssueDetail}
|
onClick={issue.sub_issues_count ? 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"
|
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} />
|
<Layers className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
|
||||||
<div className="text-xs">{issue.sub_issues_count}</div>
|
<div className="text-xs">{issue.sub_issues_count}</div>
|
||||||
@ -395,7 +402,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
|||||||
<WithDisplayPropertiesHOC
|
<WithDisplayPropertiesHOC
|
||||||
displayProperties={displayProperties}
|
displayProperties={displayProperties}
|
||||||
displayPropertyKey="attachment_count"
|
displayPropertyKey="attachment_count"
|
||||||
shouldRenderProperty={(properties) => !!properties.attachment_count}
|
shouldRenderProperty={(properties) => !!properties.attachment_count && !!issue.attachment_count}
|
||||||
>
|
>
|
||||||
<Tooltip tooltipHeading="Attachments" tooltipContent={`${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">
|
<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
|
<WithDisplayPropertiesHOC
|
||||||
displayProperties={displayProperties}
|
displayProperties={displayProperties}
|
||||||
displayPropertyKey="link"
|
displayPropertyKey="link"
|
||||||
shouldRenderProperty={(properties) => !!properties.link}
|
shouldRenderProperty={(properties) => !!properties.link && !!issue.link_count}
|
||||||
>
|
>
|
||||||
<Tooltip tooltipHeading="Links" tooltipContent={`${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">
|
<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 { observer } from "mobx-react-lite";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import size from "lodash/size";
|
import size from "lodash/size";
|
||||||
|
import isEmpty from "lodash/isEmpty";
|
||||||
// hooks
|
// hooks
|
||||||
import { useCycle, useIssues } from "hooks/store";
|
import { useCycle, useIssues } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
@ -89,7 +90,12 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
|||||||
<>
|
<>
|
||||||
<TransferIssuesModal handleClose={() => setTransferIssuesModal(false)} isOpen={transferIssuesModal} />
|
<TransferIssuesModal handleClose={() => setTransferIssuesModal(false)} isOpen={transferIssuesModal} />
|
||||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
<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 />
|
<CycleAppliedFiltersRoot />
|
||||||
|
|
||||||
{issues?.groupedIssueIds?.length === 0 ? (
|
{issues?.groupedIssueIds?.length === 0 ? (
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import differenceInCalendarDays from "date-fns/differenceInCalendarDays";
|
// hooks
|
||||||
|
import { useProjectState } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
import { DateDropdown } from "components/dropdowns";
|
import { DateDropdown } from "components/dropdowns";
|
||||||
// helpers
|
// helpers
|
||||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||||
|
import { shouldHighlightIssueDueDate } from "helpers/issue.helper";
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
// types
|
// types
|
||||||
import { TIssue } from "@plane/types";
|
import { TIssue } from "@plane/types";
|
||||||
import { cn } from "helpers/common.helper";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
issue: TIssue;
|
issue: TIssue;
|
||||||
@ -18,8 +20,10 @@ type Props = {
|
|||||||
|
|
||||||
export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props) => {
|
export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props) => {
|
||||||
const { issue, onChange, disabled, onClose } = props;
|
const { issue, onChange, disabled, onClose } = props;
|
||||||
|
// store hooks
|
||||||
const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1;
|
const { getStateById } = useProjectState();
|
||||||
|
// derived values
|
||||||
|
const stateDetails = getStateById(issue.state_id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-11 border-b-[0.5px] border-custom-border-200">
|
<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"
|
buttonVariant="transparent-with-text"
|
||||||
buttonContainerClassName="w-full"
|
buttonContainerClassName="w-full"
|
||||||
buttonClassName={cn("rounded-none text-left", {
|
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"
|
clearIconClassName="!text-custom-text-100"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
@ -65,15 +65,16 @@ export const HeaderColumn = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
onMenuClose={onClose}
|
onMenuClose={onClose}
|
||||||
placement="bottom-end"
|
placement="bottom-start"
|
||||||
closeOnSelect
|
closeOnSelect
|
||||||
>
|
>
|
||||||
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}>
|
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between gap-1.5 px-1 ${selectedMenuItem === `${propertyDetails.ascendingOrderKey}_${property}`
|
className={`flex items-center justify-between gap-1.5 px-1 ${
|
||||||
? "text-custom-text-100"
|
selectedMenuItem === `${propertyDetails.ascendingOrderKey}_${property}`
|
||||||
: "text-custom-text-200 hover:text-custom-text-100"
|
? "text-custom-text-100"
|
||||||
}`}
|
: "text-custom-text-200 hover:text-custom-text-100"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ArrowDownWideNarrow className="h-3 w-3 stroke-[1.5]" />
|
<ArrowDownWideNarrow className="h-3 w-3 stroke-[1.5]" />
|
||||||
@ -87,10 +88,11 @@ export const HeaderColumn = (props: Props) => {
|
|||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.descendingOrderKey, property)}>
|
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.descendingOrderKey, property)}>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between gap-1.5 px-1 ${selectedMenuItem === `${propertyDetails.descendingOrderKey}_${property}`
|
className={`flex items-center justify-between gap-1.5 px-1 ${
|
||||||
? "text-custom-text-100"
|
selectedMenuItem === `${propertyDetails.descendingOrderKey}_${property}`
|
||||||
: "text-custom-text-200 hover:text-custom-text-100"
|
? "text-custom-text-100"
|
||||||
}`}
|
: "text-custom-text-200 hover:text-custom-text-100"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ArrowUpNarrowWide className="h-3 w-3 stroke-[1.5]" />
|
<ArrowUpNarrowWide className="h-3 w-3 stroke-[1.5]" />
|
||||||
|
@ -5,6 +5,8 @@ import { useRouter } from "next/router";
|
|||||||
import { useApplication } from "hooks/store";
|
import { useApplication } from "hooks/store";
|
||||||
// types
|
// types
|
||||||
import { TIssue } from "@plane/types";
|
import { TIssue } from "@plane/types";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
issue: TIssue;
|
issue: TIssue;
|
||||||
@ -30,8 +32,13 @@ export const SpreadsheetSubIssueColumn: React.FC<Props> = observer((props: Props
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={redirectToIssueDetail}
|
onClick={issue?.sub_issues_count ? 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"
|
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"}
|
{issue?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,10 +19,10 @@ export const SpreadsheetHeader = (props: Props) => {
|
|||||||
const { displayProperties, displayFilters, handleDisplayFilterUpdate, isEstimateEnabled } = props;
|
const { displayProperties, displayFilters, handleDisplayFilterUpdate, isEstimateEnabled } = props;
|
||||||
|
|
||||||
return (
|
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>
|
<tr>
|
||||||
<th
|
<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}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
|
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
|
||||||
|
@ -64,6 +64,31 @@ export interface IssueFormProps {
|
|||||||
const aiService = new AIService();
|
const aiService = new AIService();
|
||||||
const fileService = new FileService();
|
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) => {
|
export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@ -271,7 +296,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
}}
|
}}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
// TODO: update tabIndex logic
|
// TODO: update tabIndex logic
|
||||||
tabIndex={19}
|
tabIndex={getTabIndex("project_id")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -294,15 +319,18 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
|
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate font-medium">{selectedParentIssue.name.substring(0, 50)}</span>
|
<span className="truncate font-medium">{selectedParentIssue.name.substring(0, 50)}</span>
|
||||||
<X
|
<button
|
||||||
className="h-3 w-3 cursor-pointer"
|
type="button"
|
||||||
|
className="grid place-items-center"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setValue("parent_id", null);
|
setValue("parent_id", null);
|
||||||
handleFormChange();
|
handleFormChange();
|
||||||
setSelectedParentIssue(null);
|
setSelectedParentIssue(null);
|
||||||
}}
|
}}
|
||||||
tabIndex={20}
|
tabIndex={getTabIndex("remove_parent")}
|
||||||
/>
|
>
|
||||||
|
<X className="h-3 w-3 cursor-pointer" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -332,7 +360,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
hasError={Boolean(errors.name)}
|
hasError={Boolean(errors.name)}
|
||||||
placeholder="Issue Title"
|
placeholder="Issue Title"
|
||||||
className="resize-none text-xl w-full"
|
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}
|
onClick={handleAutoGenerateDescription}
|
||||||
disabled={iAmFeelingLucky}
|
disabled={iAmFeelingLucky}
|
||||||
tabIndex={3}
|
tabIndex={getTabIndex("feeling_lucky")}
|
||||||
>
|
>
|
||||||
{iAmFeelingLucky ? (
|
{iAmFeelingLucky ? (
|
||||||
"Generating response"
|
"Generating response"
|
||||||
@ -375,7 +403,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
|
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
|
||||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||||
tabIndex={4}
|
tabIndex={getTabIndex("ai_assistant")}
|
||||||
>
|
>
|
||||||
<Sparkle className="h-4 w-4" />
|
<Sparkle className="h-4 w-4" />
|
||||||
AI
|
AI
|
||||||
@ -426,7 +454,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
}}
|
}}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
tabIndex={6}
|
tabIndex={getTabIndex("state_id")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -443,7 +471,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
handleFormChange();
|
handleFormChange();
|
||||||
}}
|
}}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
tabIndex={7}
|
tabIndex={getTabIndex("priority")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -464,7 +492,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""}
|
buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||||
placeholder="Assignees"
|
placeholder="Assignees"
|
||||||
multiple
|
multiple
|
||||||
tabIndex={8}
|
tabIndex={getTabIndex("assignee_ids")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -482,7 +510,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
handleFormChange();
|
handleFormChange();
|
||||||
}}
|
}}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
tabIndex={9}
|
tabIndex={getTabIndex("label_ids")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -498,6 +526,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
maxDate={maxDate ?? undefined}
|
maxDate={maxDate ?? undefined}
|
||||||
placeholder="Start date"
|
placeholder="Start date"
|
||||||
|
tabIndex={getTabIndex("start_date")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -513,6 +542,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
minDate={minDate ?? undefined}
|
minDate={minDate ?? undefined}
|
||||||
placeholder="Due date"
|
placeholder="Due date"
|
||||||
|
tabIndex={getTabIndex("target_date")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -531,7 +561,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
}}
|
}}
|
||||||
value={value}
|
value={value}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
tabIndex={11}
|
tabIndex={getTabIndex("cycle_id")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -551,7 +581,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
handleFormChange();
|
handleFormChange();
|
||||||
}}
|
}}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
tabIndex={12}
|
tabIndex={getTabIndex("module_ids")}
|
||||||
multiple
|
multiple
|
||||||
showCount
|
showCount
|
||||||
/>
|
/>
|
||||||
@ -573,7 +603,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
}}
|
}}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
buttonVariant="border-with-text"
|
buttonVariant="border-with-text"
|
||||||
tabIndex={13}
|
tabIndex={getTabIndex("estimate_point")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -603,7 +633,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
tabIndex={14}
|
tabIndex={getTabIndex("parent_id")}
|
||||||
>
|
>
|
||||||
{watch("parent_id") ? (
|
{watch("parent_id") ? (
|
||||||
<>
|
<>
|
||||||
@ -653,7 +683,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
|
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
|
||||||
}}
|
}}
|
||||||
tabIndex={15}
|
tabIndex={getTabIndex("create_more")}
|
||||||
>
|
>
|
||||||
<div className="flex cursor-pointer items-center justify-center">
|
<div className="flex cursor-pointer items-center justify-center">
|
||||||
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
|
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
|
||||||
@ -661,7 +691,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
<span className="text-xs">Create more</span>
|
<span className="text-xs">Create more</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
Discard
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -673,7 +703,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={handleSubmit((data) => handleFormSubmit({ ...data, is_draft: false }))}
|
onClick={handleSubmit((data) => handleFormSubmit({ ...data, is_draft: false }))}
|
||||||
tabIndex={17}
|
tabIndex={getTabIndex("draft_button")}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Moving" : "Move from draft"}
|
{isSubmitting ? "Moving" : "Move from draft"}
|
||||||
</Button>
|
</Button>
|
||||||
@ -683,7 +713,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={handleSubmit((data) => handleFormSubmit(data, true))}
|
onClick={handleSubmit((data) => handleFormSubmit(data, true))}
|
||||||
tabIndex={17}
|
tabIndex={getTabIndex("draft_button")}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Saving" : "Save as draft"}
|
{isSubmitting ? "Saving" : "Save as draft"}
|
||||||
</Button>
|
</Button>
|
||||||
@ -691,7 +721,13 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
</Fragment>
|
</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"}
|
{data?.id ? (isSubmitting ? "Updating" : "Update issue") : isSubmitting ? "Creating" : "Create issue"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -183,7 +183,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
|||||||
if (!workspaceSlug || !payload.project_id || !data?.id) return;
|
if (!workspaceSlug || !payload.project_id || !data?.id) return;
|
||||||
|
|
||||||
try {
|
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({
|
setToastAlert({
|
||||||
type: "success",
|
type: "success",
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
@ -191,11 +191,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
|||||||
});
|
});
|
||||||
captureIssueEvent({
|
captureIssueEvent({
|
||||||
eventName: ISSUE_UPDATED,
|
eventName: ISSUE_UPDATED,
|
||||||
payload: { ...response, state: "SUCCESS" },
|
payload: { ...payload, issueId: data.id, state: "SUCCESS" },
|
||||||
path: router.asPath,
|
path: router.asPath,
|
||||||
});
|
});
|
||||||
handleClose();
|
handleClose();
|
||||||
return response;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "error",
|
type: "error",
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { differenceInCalendarDays } from "date-fns";
|
|
||||||
import { Signal, Tag, Triangle, LayoutPanelTop, CircleDot, CopyPlus, XCircle, CalendarDays } from "lucide-react";
|
import { Signal, Tag, Triangle, LayoutPanelTop, CircleDot, CopyPlus, XCircle, CalendarDays } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail, useProject } from "hooks/store";
|
import { useIssueDetail, useProject, useProjectState } from "hooks/store";
|
||||||
// ui icons
|
// ui icons
|
||||||
import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon, RelatedIcon } from "@plane/ui";
|
import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon, RelatedIcon } from "@plane/ui";
|
||||||
import {
|
import {
|
||||||
@ -26,6 +25,7 @@ import {
|
|||||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
|
import { shouldHighlightIssueDueDate } from "helpers/issue.helper";
|
||||||
|
|
||||||
interface IPeekOverviewProperties {
|
interface IPeekOverviewProperties {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
@ -42,11 +42,13 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
const {
|
const {
|
||||||
issue: { getIssueById },
|
issue: { getIssueById },
|
||||||
} = useIssueDetail();
|
} = useIssueDetail();
|
||||||
|
const { getStateById } = useProjectState();
|
||||||
// derived values
|
// derived values
|
||||||
const issue = getIssueById(issueId);
|
const issue = getIssueById(issueId);
|
||||||
if (!issue) return <></>;
|
if (!issue) return <></>;
|
||||||
const projectDetails = getProjectById(issue.project_id);
|
const projectDetails = getProjectById(issue.project_id);
|
||||||
const isEstimateEnabled = projectDetails?.estimate;
|
const isEstimateEnabled = projectDetails?.estimate;
|
||||||
|
const stateDetails = getStateById(issue.state_id);
|
||||||
|
|
||||||
const minDate = issue.start_date ? new Date(issue.start_date) : null;
|
const minDate = issue.start_date ? new Date(issue.start_date) : null;
|
||||||
minDate?.setDate(minDate.getDate());
|
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;
|
const maxDate = issue.target_date ? new Date(issue.target_date) : null;
|
||||||
maxDate?.setDate(maxDate.getDate());
|
maxDate?.setDate(maxDate.getDate());
|
||||||
|
|
||||||
const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<h6 className="text-sm font-medium">Properties</h6>
|
<h6 className="text-sm font-medium">Properties</h6>
|
||||||
@ -169,7 +169,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
|||||||
buttonContainerClassName="w-full text-left"
|
buttonContainerClassName="w-full text-left"
|
||||||
buttonClassName={cn("text-sm", {
|
buttonClassName={cn("text-sm", {
|
||||||
"text-custom-text-400": !issue.target_date,
|
"text-custom-text-400": !issue.target_date,
|
||||||
"text-red-500": targetDateDistance <= 0,
|
"text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
|
||||||
})}
|
})}
|
||||||
hideIcon
|
hideIcon
|
||||||
clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100"
|
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 {
|
interface IIssuePeekOverview {
|
||||||
is_archived?: boolean;
|
is_archived?: boolean;
|
||||||
onIssueUpdate?: (issue: Partial<TIssue>) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TIssuePeekOperations = {
|
export type TIssuePeekOperations = {
|
||||||
@ -46,7 +45,7 @@ export type TIssuePeekOperations = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||||
const { is_archived = false, onIssueUpdate } = props;
|
const { is_archived = false } = props;
|
||||||
// hooks
|
// hooks
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
// router
|
// router
|
||||||
@ -87,7 +86,6 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
|||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await updateIssue(workspaceSlug, projectId, issueId, data);
|
const response = await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||||
if (onIssueUpdate) await onIssueUpdate(response);
|
|
||||||
if (showToast)
|
if (showToast)
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
title: "Issue updated successfully",
|
title: "Issue updated successfully",
|
||||||
@ -96,7 +94,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
|||||||
});
|
});
|
||||||
captureIssueEvent({
|
captureIssueEvent({
|
||||||
eventName: ISSUE_UPDATED,
|
eventName: ISSUE_UPDATED,
|
||||||
payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" },
|
payload: { ...data, issueId, state: "SUCCESS", element: "Issue peek-overview" },
|
||||||
updates: {
|
updates: {
|
||||||
changed_property: Object.keys(data).join(","),
|
changed_property: Object.keys(data).join(","),
|
||||||
change_details: Object.values(data).join(","),
|
change_details: Object.values(data).join(","),
|
||||||
@ -314,7 +312,6 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
|||||||
removeIssueFromModule,
|
removeIssueFromModule,
|
||||||
removeModulesFromIssue,
|
removeModulesFromIssue,
|
||||||
setToastAlert,
|
setToastAlert,
|
||||||
onIssueUpdate,
|
|
||||||
captureIssueEvent,
|
captureIssueEvent,
|
||||||
router.asPath,
|
router.asPath,
|
||||||
]
|
]
|
||||||
|
@ -154,22 +154,22 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
|
|||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`${
|
`${
|
||||||
active ? "bg-custom-background-80" : ""
|
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}
|
value={label.id}
|
||||||
>
|
>
|
||||||
{({ selected }) => (
|
{({ selected }) => (
|
||||||
<div className="flex w-full justify-between gap-2 rounded">
|
<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
|
<span
|
||||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: label.color,
|
backgroundColor: label.color,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>{label.name}</span>
|
<span className="truncate">{label.name}</span>
|
||||||
</div>
|
</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"}`} />
|
<Check className={`h-3 w-3 ${selected ? "opacity-100" : "opacity-0"}`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -235,7 +235,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<CustomMenu verticalEllipsis buttonClassName="z-[1]">
|
<CustomMenu verticalEllipsis buttonClassName="z-[1]" placement="bottom-end">
|
||||||
{isEditingAllowed && (
|
{isEditingAllowed && (
|
||||||
<>
|
<>
|
||||||
<CustomMenu.MenuItem onClick={handleEditModule}>
|
<CustomMenu.MenuItem onClick={handleEditModule}>
|
||||||
|
@ -190,6 +190,7 @@ export const UserDetails: React.FC<Props> = observer((props) => {
|
|||||||
hasError={Boolean(errors.first_name)}
|
hasError={Boolean(errors.first_name)}
|
||||||
placeholder="Enter your full name..."
|
placeholder="Enter your full name..."
|
||||||
className="w-full border-onboarding-border-100 focus:border-custom-primary-100"
|
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.last_name
|
||||||
} ${memberDetails?.member.display_name.toLowerCase()}`,
|
} ${memberDetails?.member.display_name.toLowerCase()}`,
|
||||||
content: (
|
content: (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex w-full items-center gap-2">
|
||||||
<Avatar name={memberDetails?.member.display_name} src={memberDetails?.member.avatar} />
|
<div className="flex-shrink-0 pt-0.5">
|
||||||
{memberDetails?.member.display_name} (
|
<Avatar name={memberDetails?.member.display_name} src={memberDetails?.member.avatar} />
|
||||||
{memberDetails?.member.first_name + " " + memberDetails?.member.last_name})
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
{memberDetails?.member.display_name} (
|
||||||
|
{memberDetails?.member.first_name + " " + memberDetails?.member.last_name})
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
@ -11,6 +11,7 @@ import useToast from "hooks/use-toast";
|
|||||||
import { CreateProjectModal, ProjectSidebarListItem } from "components/project";
|
import { CreateProjectModal, ProjectSidebarListItem } from "components/project";
|
||||||
// helpers
|
// helpers
|
||||||
import { copyUrlToClipboard } from "helpers/string.helper";
|
import { copyUrlToClipboard } from "helpers/string.helper";
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
// constants
|
// constants
|
||||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||||
|
|
||||||
@ -109,9 +110,9 @@ export const ProjectSidebarList: FC = observer(() => {
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`h-full space-y-2 overflow-y-auto pl-4 vertical-scrollbar scrollbar-md ${
|
className={cn("h-full space-y-2 overflow-y-auto px-4 vertical-scrollbar scrollbar-md", {
|
||||||
isScrolled ? "border-t border-custom-sidebar-border-300" : ""
|
"border-t border-custom-sidebar-border-300": isScrolled,
|
||||||
}`}
|
})}
|
||||||
>
|
>
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<Droppable droppableId="favorite-projects">
|
<Droppable droppableId="favorite-projects">
|
||||||
|
@ -80,7 +80,7 @@ export const ProjectViewListItem: React.FC<Props> = observer((props) => {
|
|||||||
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
|
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
|
||||||
<div className="group border-b border-custom-border-200 hover:bg-custom-background-90">
|
<div className="group border-b border-custom-border-200 hover:bg-custom-background-90">
|
||||||
<Link href={`/${workspaceSlug}/projects/${projectId}/views/${view.id}`}>
|
<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 w-full items-center justify-between">
|
||||||
<div className="flex items-center gap-4 overflow-hidden">
|
<div className="flex items-center gap-4 overflow-hidden">
|
||||||
<div className="flex flex-col 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