Merge branch 'develop' of github.com:makeplane/plane into preview

This commit is contained in:
sriram veeraghanta 2024-02-23 19:22:17 +05:30
commit 6240b17063
137 changed files with 2001 additions and 1716 deletions

View File

@ -69,6 +69,9 @@ from .issue import (
RelatedIssueSerializer, RelatedIssueSerializer,
IssuePublicSerializer, IssuePublicSerializer,
IssueDetailSerializer, IssueDetailSerializer,
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
) )
from .module import ( from .module import (

View File

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

View File

@ -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):

View File

@ -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):

View File

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

View File

@ -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")

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

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

View File

@ -33,7 +33,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
}, },
}, },
background: { background: {
default: "rgb(var(--color-primary-100))", default: "none",
}, },
}; };
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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({

View File

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

View File

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

View File

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

View File

@ -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 = {

View File

@ -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 = {

View File

@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,2 @@
export * from "./add-block"; export * from "./add-block";
export * from "./block-structure";
export * from "./draggable"; export * from "./draggable";

View File

@ -0,0 +1 @@
export * from "./use-gantt-chart";

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(","),

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
}`} }`}
> >

View File

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

View File

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

View File

@ -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={() => {

View File

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

View File

@ -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 ? (

View File

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

View File

@ -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]" />

View File

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

View File

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

View File

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

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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