Merge pull request #1758 from makeplane/stage-release

promote: stage-release to master v0.10
This commit is contained in:
Vamsi Kurama 2023-08-01 20:04:40 +05:30 committed by GitHub
commit 2ced7e4911
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
258 changed files with 12068 additions and 5203 deletions

View File

@ -2,7 +2,7 @@
<p align="center"> <p align="center">
<a href="https://plane.so"> <a href="https://plane.so">
<img src="https://res.cloudinary.com/toolspacedev/image/upload/v1680596414/Plane/Plane_Icon_Blue_on_White_150x150_muysa3.jpg" alt="Plane Logo" width="70"> <img src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_logo_.webp" alt="Plane Logo" width="70">
</a> </a>
</p> </p>
@ -11,22 +11,22 @@
<p align="center"> <p align="center">
<a href="https://discord.com/invite/A92xrEGCge"> <a href="https://discord.com/invite/A92xrEGCge">
<img alt="Discord" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" /> <img alt="Discord online members" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
</a> </a>
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" /> <img alt="Commit activity per month" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
</p> </p>
<p> <p>
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank"> <a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Screen.png" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_screen.webp"
alt="Plane Screens" alt="Plane Screens"
width="100%" width="100%"
/> />
</a> </a>
<a href="https://app.plane.so/#gh-dark-mode-only" target="_blank"> <a href="https://app.plane.so/#gh-dark-mode-only" target="_blank">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Screens+Dark+Mode.png" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_screens_dark_mode.webp"
alt="Plane Screens" alt="Plane Screens"
width="100%" width="100%"
/> />
@ -86,7 +86,7 @@ docker compose up -d
<p> <p>
<a href="https://plane.so" target="_blank"> <a href="https://plane.so" target="_blank">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Views+Dark+Mode.png" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_views_dark_mode.webp"
alt="Plane Views" alt="Plane Views"
width="100%" width="100%"
/> />
@ -95,7 +95,7 @@ docker compose up -d
<p> <p>
<a href="https://plane.so" target="_blank"> <a href="https://plane.so" target="_blank">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Issue+Detail+Dark+Mode.png" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_issue_detail_dark_mode.webp"
alt="Plane Issue Details" alt="Plane Issue Details"
width="100%" width="100%"
/> />
@ -104,7 +104,7 @@ docker compose up -d
<p> <p>
<a href="https://plane.so" target="_blank"> <a href="https://plane.so" target="_blank">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Cycles+%26+Modules+Dark+Mode.png" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_cycles_modules_dark_mode.webp"
alt="Plane Cycles and Modules" alt="Plane Cycles and Modules"
width="100%" width="100%"
/> />
@ -113,7 +113,7 @@ docker compose up -d
<p> <p>
<a href="https://plane.so" target="_blank"> <a href="https://plane.so" target="_blank">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Analytics+Dark+Mode.png" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_analytics_dark_mode.webp"
alt="Plane Analytics" alt="Plane Analytics"
width="100%" width="100%"
/> />
@ -122,7 +122,7 @@ docker compose up -d
<p> <p>
<a href="https://plane.so" target="_blank"> <a href="https://plane.so" target="_blank">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Pages+Dark+Mode.png" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_pages_dark_mode.webp"
alt="Plane Pages" alt="Plane Pages"
width="100%" width="100%"
/> />
@ -132,7 +132,7 @@ docker compose up -d
<p> <p>
<a href="https://plane.so" target="_blank"> <a href="https://plane.so" target="_blank">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Commad+K+Dark+Mode.png" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_commad_k_dark_mode.webp"
alt="Plane Command Menu" alt="Plane Command Menu"
width="100%" width="100%"
/> />

View File

@ -1,2 +1,2 @@
from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission, WorkspaceViewerPermission
from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission

View File

@ -61,3 +61,13 @@ class WorkspaceEntityPermission(BasePermission):
return WorkspaceMember.objects.filter( return WorkspaceMember.objects.filter(
member=request.user, workspace__slug=view.workspace_slug member=request.user, workspace__slug=view.workspace_slug
).exists() ).exists()
class WorkspaceViewerPermission(BasePermission):
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
return WorkspaceMember.objects.filter(
member=request.user, workspace__slug=view.workspace_slug, role__gte=10
).exists()

View File

@ -25,7 +25,7 @@ from .project import (
) )
from .state import StateSerializer, StateLiteSerializer from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer from .view import IssueViewSerializer, IssueViewFavoriteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer
from .asset import FileAssetSerializer from .asset import FileAssetSerializer
from .issue import ( from .issue import (
IssueCreateSerializer, IssueCreateSerializer,
@ -43,6 +43,8 @@ from .issue import (
IssueLiteSerializer, IssueLiteSerializer,
IssueAttachmentSerializer, IssueAttachmentSerializer,
IssueSubscriberSerializer, IssueSubscriberSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
) )
from .module import ( from .module import (

View File

@ -12,6 +12,12 @@ from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer from .project import ProjectLiteSerializer
from plane.db.models import Cycle, CycleIssue, CycleFavorite from plane.db.models import Cycle, CycleIssue, CycleFavorite
class CycleWriteSerializer(BaseSerializer):
class Meta:
model = Cycle
fields = "__all__"
class CycleSerializer(BaseSerializer): class CycleSerializer(BaseSerializer):
owned_by = UserLiteSerializer(read_only=True) owned_by = UserLiteSerializer(read_only=True)

View File

@ -29,6 +29,8 @@ from plane.db.models import (
ModuleIssue, ModuleIssue,
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
IssueReaction,
CommentReaction,
) )
@ -50,6 +52,20 @@ class IssueFlatSerializer(BaseSerializer):
] ]
class IssueProjectLiteSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(source="project", read_only=True)
class Meta:
model = Issue
fields = [
"id",
"project_detail",
"name",
"sequence_id",
]
read_only_fields = fields
##TODO: Find a better way to write this serializer ##TODO: Find a better way to write this serializer
## Find a better approach to save manytomany? ## Find a better approach to save manytomany?
class IssueCreateSerializer(BaseSerializer): class IssueCreateSerializer(BaseSerializer):
@ -101,8 +117,15 @@ class IssueCreateSerializer(BaseSerializer):
labels = validated_data.pop("labels_list", None) labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None) blocks = validated_data.pop("blocks_list", None)
project = self.context["project"] project_id = self.context["project_id"]
issue = Issue.objects.create(**validated_data, project=project) workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
issue = Issue.objects.create(**validated_data, project_id=project_id)
# Issue Audit Users
created_by_id = issue.created_by_id
updated_by_id = issue.updated_by_id
if blockers is not None and len(blockers): if blockers is not None and len(blockers):
IssueBlocker.objects.bulk_create( IssueBlocker.objects.bulk_create(
@ -110,10 +133,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueBlocker( IssueBlocker(
block=issue, block=issue,
blocked_by=blocker, blocked_by=blocker,
project=project, project_id=project_id,
workspace=project.workspace, workspace_id=workspace_id,
created_by=issue.created_by, created_by_id=created_by_id,
updated_by=issue.updated_by, updated_by_id=updated_by_id,
) )
for blocker in blockers for blocker in blockers
], ],
@ -126,10 +149,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueAssignee( IssueAssignee(
assignee=user, assignee=user,
issue=issue, issue=issue,
project=project, project_id=project_id,
workspace=project.workspace, workspace_id=workspace_id,
created_by=issue.created_by, created_by_id=created_by_id,
updated_by=issue.updated_by, updated_by_id=updated_by_id,
) )
for user in assignees for user in assignees
], ],
@ -137,14 +160,14 @@ class IssueCreateSerializer(BaseSerializer):
) )
else: else:
# Then assign it to default assignee # Then assign it to default assignee
if project.default_assignee is not None: if default_assignee_id is not None:
IssueAssignee.objects.create( IssueAssignee.objects.create(
assignee=project.default_assignee, assignee_id=default_assignee_id,
issue=issue, issue=issue,
project=project, project_id=project_id,
workspace=project.workspace, workspace_id=workspace_id,
created_by=issue.created_by, created_by_id=created_by_id,
updated_by=issue.updated_by, updated_by_id=updated_by_id,
) )
if labels is not None and len(labels): if labels is not None and len(labels):
@ -153,10 +176,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueLabel( IssueLabel(
label=label, label=label,
issue=issue, issue=issue,
project=project, project_id=project_id,
workspace=project.workspace, workspace_id=workspace_id,
created_by=issue.created_by, created_by_id=created_by_id,
updated_by=issue.updated_by, updated_by_id=updated_by_id,
) )
for label in labels for label in labels
], ],
@ -169,10 +192,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueBlocker( IssueBlocker(
block=block, block=block,
blocked_by=issue, blocked_by=issue,
project=project, project_id=project_id,
workspace=project.workspace, workspace_id=workspace_id,
created_by=issue.created_by, created_by_id=created_by_id,
updated_by=issue.updated_by, updated_by_id=updated_by_id,
) )
for block in blocks for block in blocks
], ],
@ -187,6 +210,12 @@ class IssueCreateSerializer(BaseSerializer):
labels = validated_data.pop("labels_list", None) labels = validated_data.pop("labels_list", None)
blocks = validated_data.pop("blocks_list", None) blocks = validated_data.pop("blocks_list", None)
# Related models
project_id = instance.project_id
workspace_id = instance.workspace_id
created_by_id = instance.created_by_id
updated_by_id = instance.updated_by_id
if blockers is not None: if blockers is not None:
IssueBlocker.objects.filter(block=instance).delete() IssueBlocker.objects.filter(block=instance).delete()
IssueBlocker.objects.bulk_create( IssueBlocker.objects.bulk_create(
@ -194,10 +223,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueBlocker( IssueBlocker(
block=instance, block=instance,
blocked_by=blocker, blocked_by=blocker,
project=instance.project, project_id=project_id,
workspace=instance.project.workspace, workspace_id=workspace_id,
created_by=instance.created_by, created_by_id=created_by_id,
updated_by=instance.updated_by, updated_by_id=updated_by_id,
) )
for blocker in blockers for blocker in blockers
], ],
@ -211,10 +240,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueAssignee( IssueAssignee(
assignee=user, assignee=user,
issue=instance, issue=instance,
project=instance.project, project_id=project_id,
workspace=instance.project.workspace, workspace_id=workspace_id,
created_by=instance.created_by, created_by_id=created_by_id,
updated_by=instance.updated_by, updated_by_id=updated_by_id,
) )
for user in assignees for user in assignees
], ],
@ -228,10 +257,10 @@ class IssueCreateSerializer(BaseSerializer):
IssueLabel( IssueLabel(
label=label, label=label,
issue=instance, issue=instance,
project=instance.project, project_id=project_id,
workspace=instance.project.workspace, workspace_id=workspace_id,
created_by=instance.created_by, created_by_id=created_by_id,
updated_by=instance.updated_by, updated_by_id=updated_by_id,
) )
for label in labels for label in labels
], ],
@ -245,23 +274,25 @@ class IssueCreateSerializer(BaseSerializer):
IssueBlocker( IssueBlocker(
block=block, block=block,
blocked_by=instance, blocked_by=instance,
project=instance.project, project_id=project_id,
workspace=instance.project.workspace, workspace_id=workspace_id,
created_by=instance.created_by, created_by_id=created_by_id,
updated_by=instance.updated_by, updated_by_id=updated_by_id,
) )
for block in blocks for block in blocks
], ],
batch_size=10, batch_size=10,
) )
# Time updation occues even when other related models are updated
instance.updated_at = timezone.now() instance.updated_at = timezone.now()
return super().update(instance, validated_data) return super().update(instance, validated_data)
class IssueActivitySerializer(BaseSerializer): class IssueActivitySerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor") actor_detail = UserLiteSerializer(read_only=True, source="actor")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta: class Meta:
model = IssueActivity model = IssueActivity
@ -335,19 +366,31 @@ class IssueLabelSerializer(BaseSerializer):
class BlockedIssueSerializer(BaseSerializer): class BlockedIssueSerializer(BaseSerializer):
blocked_issue_detail = IssueFlatSerializer(source="block", read_only=True) blocked_issue_detail = IssueProjectLiteSerializer(source="block", read_only=True)
class Meta: class Meta:
model = IssueBlocker model = IssueBlocker
fields = "__all__" fields = [
"blocked_issue_detail",
"blocked_by",
"block",
]
read_only_fields = fields
class BlockerIssueSerializer(BaseSerializer): class BlockerIssueSerializer(BaseSerializer):
blocker_issue_detail = IssueFlatSerializer(source="blocked_by", read_only=True) blocker_issue_detail = IssueProjectLiteSerializer(
source="blocked_by", read_only=True
)
class Meta: class Meta:
model = IssueBlocker model = IssueBlocker
fields = "__all__" fields = [
"blocker_issue_detail",
"blocked_by",
"block",
]
read_only_fields = fields
class IssueAssigneeSerializer(BaseSerializer): class IssueAssigneeSerializer(BaseSerializer):
@ -460,6 +503,89 @@ class IssueAttachmentSerializer(BaseSerializer):
] ]
class IssueReactionSerializer(BaseSerializer):
class Meta:
model = IssueReaction
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
"actor",
]
class IssueReactionLiteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueReaction
fields = [
"id",
"reaction",
"issue",
"actor_detail",
]
class CommentReactionLiteSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = CommentReaction
fields = [
"id",
"reaction",
"comment",
"actor_detail",
]
class CommentReactionSerializer(BaseSerializer):
class Meta:
model = CommentReaction
fields = "__all__"
read_only_fields = ["workspace", "project", "comment", "actor"]
class IssueCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True)
class Meta:
model = IssueComment
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssueStateFlatSerializer(BaseSerializer):
state_detail = StateLiteSerializer(read_only=True, source="state")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = Issue
fields = [
"id",
"sequence_id",
"name",
"state_detail",
"project_detail",
]
# Issue Serializer with state details # Issue Serializer with state details
class IssueStateSerializer(BaseSerializer): class IssueStateSerializer(BaseSerializer):
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
@ -479,7 +605,7 @@ class IssueStateSerializer(BaseSerializer):
class IssueSerializer(BaseSerializer): class IssueSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project") project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateSerializer(read_only=True, source="state") state_detail = StateSerializer(read_only=True, source="state")
parent_detail = IssueFlatSerializer(read_only=True, source="parent") parent_detail = IssueStateFlatSerializer(read_only=True, source="parent")
label_details = LabelSerializer(read_only=True, source="labels", many=True) label_details = LabelSerializer(read_only=True, source="labels", many=True)
assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True)
# List of issues blocked by this issue # List of issues blocked by this issue
@ -491,6 +617,7 @@ class IssueSerializer(BaseSerializer):
issue_link = IssueLinkSerializer(read_only=True, many=True) issue_link = IssueLinkSerializer(read_only=True, many=True)
issue_attachment = IssueAttachmentSerializer(read_only=True, many=True) issue_attachment = IssueAttachmentSerializer(read_only=True, many=True)
sub_issues_count = serializers.IntegerField(read_only=True) sub_issues_count = serializers.IntegerField(read_only=True)
issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True)
class Meta: class Meta:
model = Issue model = Issue
@ -516,6 +643,7 @@ class IssueLiteSerializer(BaseSerializer):
module_id = serializers.UUIDField(read_only=True) module_id = serializers.UUIDField(read_only=True)
attachment_count = serializers.IntegerField(read_only=True) attachment_count = serializers.IntegerField(read_only=True)
link_count = serializers.IntegerField(read_only=True) link_count = serializers.IntegerField(read_only=True)
issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True)
class Meta: class Meta:
model = Issue model = Issue

View File

@ -3,7 +3,7 @@ from rest_framework import serializers
# Module imports # Module imports
from .base import BaseSerializer from .base import BaseSerializer
from .issue import IssueFlatSerializer, LabelSerializer from .issue import IssueFlatSerializer, LabelLiteSerializer
from .workspace import WorkspaceLiteSerializer from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer from .project import ProjectLiteSerializer
from plane.db.models import Page, PageBlock, PageFavorite, PageLabel, Label from plane.db.models import Page, PageBlock, PageFavorite, PageLabel, Label
@ -23,16 +23,22 @@ class PageBlockSerializer(BaseSerializer):
"page", "page",
] ]
class PageBlockLiteSerializer(BaseSerializer):
class Meta:
model = PageBlock
fields = "__all__"
class PageSerializer(BaseSerializer): class PageSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True) is_favorite = serializers.BooleanField(read_only=True)
label_details = LabelSerializer(read_only=True, source="labels", many=True) label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
labels_list = serializers.ListField( labels_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True, write_only=True,
required=False, required=False,
) )
blocks = PageBlockSerializer(read_only=True, many=True) blocks = PageBlockLiteSerializer(read_only=True, many=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True) project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)

View File

@ -93,6 +93,7 @@ class ProjectDetailSerializer(BaseSerializer):
total_cycles = serializers.IntegerField(read_only=True) total_cycles = serializers.IntegerField(read_only=True)
total_modules = serializers.IntegerField(read_only=True) total_modules = serializers.IntegerField(read_only=True)
is_member = serializers.BooleanField(read_only=True) is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True)
class Meta: class Meta:
model = Project model = Project

View File

@ -45,6 +45,11 @@ from plane.api.views import (
UserIssueCompletedGraphEndpoint, UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint, UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet, WorkspaceThemeViewSet,
WorkspaceUserProfileStatsEndpoint,
WorkspaceUserActivityEndpoint,
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
## End Workspaces ## End Workspaces
# File Assets # File Assets
FileAssetEndpoint, FileAssetEndpoint,
@ -79,6 +84,8 @@ from plane.api.views import (
IssueAttachmentEndpoint, IssueAttachmentEndpoint,
IssueArchiveViewSet, IssueArchiveViewSet,
IssueSubscriberViewSet, IssueSubscriberViewSet,
IssueReactionViewSet,
CommentReactionViewSet,
## End Issues ## End Issues
# States # States
StateViewSet, StateViewSet,
@ -385,6 +392,31 @@ urlpatterns = [
), ),
name="workspace-themes", name="workspace-themes",
), ),
path(
"workspaces/<str:slug>/user-stats/<uuid:user_id>/",
WorkspaceUserProfileStatsEndpoint.as_view(),
name="workspace-user-stats",
),
path(
"workspaces/<str:slug>/user-activity/<uuid:user_id>/",
WorkspaceUserActivityEndpoint.as_view(),
name="workspace-user-activity",
),
path(
"workspaces/<str:slug>/user-profile/<uuid:user_id>/",
WorkspaceUserProfileEndpoint.as_view(),
name="workspace-user-profile-page",
),
path(
"workspaces/<str:slug>/user-issues/<uuid:user_id>/",
WorkspaceUserProfileIssuesEndpoint.as_view(),
name="workspace-user-profile-issues",
),
path(
"workspaces/<str:slug>/labels/",
WorkspaceLabelsEndpoint.as_view(),
name="workspace-labels",
),
## End Workspaces ## ## End Workspaces ##
# Projects # Projects
path( path(
@ -836,6 +868,48 @@ urlpatterns = [
name="project-issue-subscribers", name="project-issue-subscribers",
), ),
## End Issue Subscribers ## End Issue Subscribers
# Issue Reactions
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/reactions/",
IssueReactionViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-reactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/reactions/<str:reaction_code>/",
IssueReactionViewSet.as_view(
{
"delete": "destroy",
}
),
name="project-issue-reactions",
),
## End Issue Reactions
# Comment Reactions
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/reactions/",
CommentReactionViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-comment-reactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/",
CommentReactionViewSet.as_view(
{
"delete": "destroy",
}
),
name="project-issue-comment-reactions",
),
## End Comment Reactions
## IssueProperty ## IssueProperty
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/", "workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/",
@ -1240,7 +1314,7 @@ urlpatterns = [
## End Importer ## End Importer
# Search # Search
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/search/", "workspaces/<str:slug>/search/",
GlobalSearchEndpoint.as_view(), GlobalSearchEndpoint.as_view(),
name="global-search", name="global-search",
), ),

View File

@ -42,6 +42,11 @@ from .workspace import (
UserIssueCompletedGraphEndpoint, UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint, UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet, WorkspaceThemeViewSet,
WorkspaceUserProfileStatsEndpoint,
WorkspaceUserActivityEndpoint,
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
) )
from .state import StateViewSet from .state import StateViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
@ -68,6 +73,8 @@ from .issue import (
IssueAttachmentEndpoint, IssueAttachmentEndpoint,
IssueArchiveViewSet, IssueArchiveViewSet,
IssueSubscriberViewSet, IssueSubscriberViewSet,
CommentReactionViewSet,
IssueReactionViewSet,
) )
from .auth_extended import ( from .auth_extended import (

View File

@ -279,6 +279,8 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Clean up
email = email.strip().lower()
validate_email(email) validate_email(email)
## Generate a random token ## Generate a random token
@ -346,7 +348,7 @@ class MagicSignInEndpoint(BaseAPIView):
def post(self, request): def post(self, request):
try: try:
user_token = request.data.get("token", "").strip() user_token = request.data.get("token", "").strip()
key = request.data.get("key", False) key = request.data.get("key", False).strip().lower()
if not key or user_token == "": if not key or user_token == "":
return Response( return Response(

View File

@ -31,6 +31,7 @@ from plane.api.serializers import (
CycleIssueSerializer, CycleIssueSerializer,
CycleFavoriteSerializer, CycleFavoriteSerializer,
IssueStateSerializer, IssueStateSerializer,
CycleWriteSerializer,
) )
from plane.api.permissions import ProjectEntityPermission from plane.api.permissions import ProjectEntityPermission
from plane.db.models import ( from plane.db.models import (
@ -338,7 +339,7 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
serializer = CycleSerializer(cycle, data=request.data, partial=True) serializer = CycleWriteSerializer(cycle, data=request.data, partial=True)
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@ -691,7 +692,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
return Response( return Response(
{ {
"error": "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", "error": "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
"cycles": CycleSerializer(cycles, many=True).data,
"status": False, "status": False,
} }
) )

View File

@ -46,8 +46,11 @@ from plane.api.serializers import (
IssueAttachmentSerializer, IssueAttachmentSerializer,
IssueSubscriberSerializer, IssueSubscriberSerializer,
ProjectMemberLiteSerializer, ProjectMemberLiteSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
) )
from plane.api.permissions import ( from plane.api.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission, ProjectEntityPermission,
WorkSpaceAdminPermission, WorkSpaceAdminPermission,
ProjectMemberPermission, ProjectMemberPermission,
@ -65,6 +68,8 @@ from plane.db.models import (
State, State,
IssueSubscriber, IssueSubscriber,
ProjectMember, ProjectMember,
IssueReaction,
CommentReaction,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results from plane.utils.grouper import group_results
@ -151,13 +156,19 @@ class IssueViewSet(BaseViewSet):
.select_related("parent") .select_related("parent")
.prefetch_related("assignees") .prefetch_related("assignees")
.prefetch_related("labels") .prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
) )
@method_decorator(gzip_page) @method_decorator(gzip_page)
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
try: try:
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true") print(filters)
# Custom ordering for priority and state # Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None] priority_order = ["urgent", "high", "medium", "low", None]
@ -244,12 +255,6 @@ class IssueViewSet(BaseViewSet):
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) issue_queryset = issue_queryset.order_by(order_by_param)
issue_queryset = (
issue_queryset
if show_sub_issues == "true"
else issue_queryset.filter(parent__isnull=True)
)
issues = IssueLiteSerializer(issue_queryset, many=True).data issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results ## Grouping the results
@ -262,7 +267,7 @@ class IssueViewSet(BaseViewSet):
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
except Exception as e: except Exception as e:
print(e) capture_exception(e)
return Response( return Response(
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@ -270,9 +275,15 @@ class IssueViewSet(BaseViewSet):
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
try: try:
project = Project.objects.get(workspace__slug=slug, pk=project_id) project = Project.objects.get(pk=project_id)
serializer = IssueCreateSerializer( serializer = IssueCreateSerializer(
data=request.data, context={"project": project} data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
},
) )
if serializer.is_valid(): if serializer.is_valid():
@ -311,9 +322,17 @@ class UserWorkSpaceIssues(BaseAPIView):
@method_decorator(gzip_page) @method_decorator(gzip_page)
def get(self, request, slug): def get(self, request, slug):
try: try:
issues = ( filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.filter( Issue.issue_objects.filter(
assignees__in=[request.user], workspace__slug=slug (Q(assignees__in=[request.user]) | Q(created_by=request.user)),
workspace__slug=slug,
) )
.annotate( .annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
@ -327,7 +346,7 @@ class UserWorkSpaceIssues(BaseAPIView):
.select_related("parent") .select_related("parent")
.prefetch_related("assignees") .prefetch_related("assignees")
.prefetch_related("labels") .prefetch_related("labels")
.order_by("-created_at") .order_by(order_by_param)
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by() .order_by()
@ -342,9 +361,77 @@ class UserWorkSpaceIssues(BaseAPIView):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.filter(**filters)
) )
serializer = IssueLiteSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) # Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
return Response(
group_results(issues, group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
except Exception as e: except Exception as e:
capture_exception(e) capture_exception(e)
return Response( return Response(
@ -390,12 +477,13 @@ class IssueActivityEndpoint(BaseAPIView):
~Q(field="comment"), ~Q(field="comment"),
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
) )
.select_related("actor", "workspace") .select_related("actor", "workspace", "issue", "project")
).order_by("created_at") ).order_by("created_at")
issue_comments = ( issue_comments = (
IssueComment.objects.filter(issue_id=issue_id) IssueComment.objects.filter(issue_id=issue_id)
.filter(project__project_projectmember__member=self.request.user) .filter(project__project_projectmember__member=self.request.user)
.order_by("created_at") .order_by("created_at")
.select_related("actor", "issue", "project", "workspace")
) )
issue_activities = IssueActivitySerializer(issue_activities, many=True).data issue_activities = IssueActivitySerializer(issue_activities, many=True).data
issue_comments = IssueCommentSerializer(issue_comments, many=True).data issue_comments = IssueCommentSerializer(issue_comments, many=True).data
@ -418,7 +506,7 @@ class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer serializer_class = IssueCommentSerializer
model = IssueComment model = IssueComment
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectLitePermission,
] ]
filterset_fields = [ filterset_fields = [
@ -628,9 +716,7 @@ class SubIssuesEndpoint(BaseAPIView):
def get(self, request, slug, project_id, issue_id): def get(self, request, slug, project_id, issue_id):
try: try:
sub_issues = ( sub_issues = (
Issue.issue_objects.filter( Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug)
parent_id=issue_id, workspace__slug=slug, project_id=project_id
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("state") .select_related("state")
@ -660,9 +746,7 @@ class SubIssuesEndpoint(BaseAPIView):
) )
state_distribution = ( state_distribution = (
State.objects.filter( State.objects.filter(~Q(name="Triage"), workspace__slug=slug)
~Q(name="Triage"), workspace__slug=slug, project_id=project_id
)
.annotate( .annotate(
state_count=Count( state_count=Count(
"state_issue", "state_issue",
@ -1096,7 +1180,8 @@ class IssueArchiveViewSet(BaseViewSet):
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
except Issue.DoesNotExist: except Issue.DoesNotExist:
return Response( return Response(
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND) {"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e: except Exception as e:
capture_exception(e) capture_exception(e)
return Response( return Response(
@ -1104,6 +1189,7 @@ class IssueArchiveViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
class IssueSubscriberViewSet(BaseViewSet): class IssueSubscriberViewSet(BaseViewSet):
serializer_class = IssueSubscriberSerializer serializer_class = IssueSubscriberSerializer
model = IssueSubscriber model = IssueSubscriber
@ -1144,18 +1230,22 @@ class IssueSubscriberViewSet(BaseViewSet):
def list(self, request, slug, project_id, issue_id): def list(self, request, slug, project_id, issue_id):
try: try:
members = ProjectMember.objects.filter( members = (
workspace__slug=slug, project_id=project_id ProjectMember.objects.filter(
).annotate( workspace__slug=slug, project_id=project_id
is_subscribed=Exists( )
IssueSubscriber.objects.filter( .annotate(
workspace__slug=slug, is_subscribed=Exists(
project_id=project_id, IssueSubscriber.objects.filter(
issue_id=issue_id, workspace__slug=slug,
subscriber=OuterRef("member"), project_id=project_id,
issue_id=issue_id,
subscriber=OuterRef("member"),
)
) )
) )
).select_related("member") .select_related("member")
)
serializer = ProjectMemberLiteSerializer(members, many=True) serializer = ProjectMemberLiteSerializer(members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e: except Exception as e:
@ -1255,3 +1345,103 @@ class IssueSubscriberViewSet(BaseViewSet):
{"error": "Something went wrong, please try again later"}, {"error": "Something went wrong, please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
class IssueReactionViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
permission_classes = [
ProjectLitePermission,
]
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user)
.order_by("-created_at")
.distinct()
)
def perform_create(self, serializer):
serializer.save(
issue_id=self.kwargs.get("issue_id"),
project_id=self.kwargs.get("project_id"),
actor=self.request.user,
)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
try:
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
reaction=reaction_code,
actor=request.user,
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist:
return Response(
{"error": "Issue reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class CommentReactionViewSet(BaseViewSet):
serializer_class = CommentReactionSerializer
model = CommentReaction
permission_classes = [
ProjectLitePermission,
]
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(comment_id=self.kwargs.get("comment_id"))
.filter(project__project_projectmember__member=self.request.user)
.order_by("-created_at")
.distinct()
)
def perform_create(self, serializer):
serializer.save(
actor=self.request.user,
comment_id=self.kwargs.get("comment_id"),
project_id=self.kwargs.get("project_id"),
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
try:
comment_reaction = CommentReaction.objects.get(
workspace__slug=slug,
project_id=project_id,
comment_id=comment_id,
reaction=reaction_code,
actor=request.user,
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist:
return Response(
{"error": "Comment reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -6,14 +6,15 @@ from django.utils import timezone
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
from plane.utils.paginator import BasePaginator
# Module imports # Module imports
from .base import BaseViewSet, BaseAPIView from .base import BaseViewSet, BaseAPIView
from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue, WorkspaceMember
from plane.api.serializers import NotificationSerializer from plane.api.serializers import NotificationSerializer
class NotificationViewSet(BaseViewSet): class NotificationViewSet(BaseViewSet, BasePaginator):
model = Notification model = Notification
serializer_class = NotificationSerializer serializer_class = NotificationSerializer
@ -37,9 +38,13 @@ class NotificationViewSet(BaseViewSet):
# Filter type # Filter type
type = request.GET.get("type", "all") type = request.GET.get("type", "all")
notifications = Notification.objects.filter( notifications = (
workspace__slug=slug, receiver_id=request.user.id Notification.objects.filter(
).order_by("snoozed_till", "-created_at") workspace__slug=slug, receiver_id=request.user.id
)
.select_related("workspace", "project", "triggered_by", "receiver")
.order_by("snoozed_till", "-created_at")
)
# Filter for snoozed notifications # Filter for snoozed notifications
if snoozed == "false": if snoozed == "false":
@ -78,10 +83,23 @@ class NotificationViewSet(BaseViewSet):
# Created issues # Created issues
if type == "created": if type == "created":
issue_ids = Issue.objects.filter( if WorkspaceMember.objects.filter(workspace__slug=slug, member=request.user, role__lt=15).exists():
workspace__slug=slug, created_by=request.user notifications = Notification.objects.none()
).values_list("pk", flat=True) else:
notifications = notifications.filter(entity_identifier__in=issue_ids) issue_ids = Issue.objects.filter(
workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
# Pagination
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
return self.paginate(
request=request,
queryset=(notifications),
on_results=lambda notifications: NotificationSerializer(
notifications, many=True
).data,
)
serializer = NotificationSerializer(notifications, many=True) serializer = NotificationSerializer(notifications, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -140,7 +140,7 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
def get(self, request): def get(self, request):
try: try:
queryset = IssueActivity.objects.filter(actor=request.user).select_related( queryset = IssueActivity.objects.filter(actor=request.user).select_related(
"actor", "workspace" "actor", "workspace", "issue", "project"
) )
return self.paginate( return self.paginate(

View File

@ -5,7 +5,7 @@ from datetime import datetime
# Django imports # Django imports
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Q, Exists, OuterRef, Func, F from django.db.models import Q, Exists, OuterRef, Func, F, Min, Subquery
from django.core.validators import validate_email from django.core.validators import validate_email
from django.conf import settings from django.conf import settings
@ -91,6 +91,24 @@ class ProjectViewSet(BaseViewSet):
) )
) )
) )
.annotate(
total_members=ProjectMember.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
total_cycles=Cycle.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
total_modules=Module.objects.filter(project_id=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.distinct() .distinct()
) )
@ -102,10 +120,16 @@ class ProjectViewSet(BaseViewSet):
project_id=OuterRef("pk"), project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
) )
sort_order_query = ProjectMember.objects.filter(
member=request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
).values("sort_order")
projects = ( projects = (
self.get_queryset() self.get_queryset()
.annotate(is_favorite=Exists(subquery)) .annotate(is_favorite=Exists(subquery))
.order_by("-is_favorite", "name") .annotate(sort_order=Subquery(sort_order_query))
.order_by("sort_order", "name")
.annotate( .annotate(
total_members=ProjectMember.objects.filter( total_members=ProjectMember.objects.filter(
project_id=OuterRef("id") project_id=OuterRef("id")
@ -152,10 +176,17 @@ class ProjectViewSet(BaseViewSet):
serializer.save() serializer.save()
# Add the user as Administrator to the project # Add the user as Administrator to the project
ProjectMember.objects.create( project_member = ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20 project_id=serializer.data["id"], member=request.user, role=20
) )
if serializer.data["project_lead"] is not None:
ProjectMember.objects.create(
project_id=serializer.data["id"],
member_id=serializer.data["project_lead"],
role=20,
)
# Default states # Default states
states = [ states = [
{ {
@ -207,9 +238,11 @@ class ProjectViewSet(BaseViewSet):
] ]
) )
return Response(serializer.data, status=status.HTTP_201_CREATED) data = serializer.data
data["sort_order"] = project_member.sort_order
return Response(data, status=status.HTTP_201_CREATED)
return Response( return Response(
[serializer.errors[error][0] for error in serializer.errors], serializer.errors,
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
except IntegrityError as e: except IntegrityError as e:
@ -567,17 +600,26 @@ class AddMemberToProjectEndpoint(BaseAPIView):
{"error": "Atleast one member is required"}, {"error": "Atleast one member is required"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
bulk_project_members = []
project_members = ProjectMember.objects.bulk_create( project_members = ProjectMember.objects.filter(
[ workspace=self.workspace, member_id__in=[member.get("member_id") for member in members]
).values("member_id").annotate(sort_order_min=Min("sort_order"))
for member in members:
sort_order = [project_member.get("sort_order") for project_member in project_members]
bulk_project_members.append(
ProjectMember( ProjectMember(
member_id=member.get("member_id"), member_id=member.get("member_id"),
role=member.get("role", 10), role=member.get("role", 10),
project_id=project_id, project_id=project_id,
workspace_id=project.workspace_id, workspace_id=project.workspace_id,
sort_order=sort_order[0] - 10000 if len(sort_order) else 65535
) )
for member in members )
],
project_members = ProjectMember.objects.bulk_create(
bulk_project_members,
batch_size=10, batch_size=10,
ignore_conflicts=True, ignore_conflicts=True,
) )
@ -819,11 +861,15 @@ class ProjectUserViewsEndpoint(BaseAPIView):
view_props = project_member.view_props view_props = project_member.view_props
default_props = project_member.default_props default_props = project_member.default_props
preferences = project_member.preferences
sort_order = project_member.sort_order
project_member.view_props = request.data.get("view_props", view_props) project_member.view_props = request.data.get("view_props", view_props)
project_member.default_props = request.data.get( project_member.default_props = request.data.get(
"default_props", default_props "default_props", default_props
) )
project_member.preferences = request.data.get("preferences", preferences)
project_member.sort_order = request.data.get("sort_order", sort_order)
project_member.save() project_member.save()

View File

@ -20,7 +20,7 @@ class GlobalSearchEndpoint(BaseAPIView):
also show related workspace if found also show related workspace if found
""" """
def filter_workspaces(self, query, slug, project_id): def filter_workspaces(self, query, slug, project_id, workspace_search):
fields = ["name"] fields = ["name"]
q = Q() q = Q()
for field in fields: for field in fields:
@ -31,8 +31,8 @@ class GlobalSearchEndpoint(BaseAPIView):
.values("name", "id", "slug") .values("name", "id", "slug")
) )
def filter_projects(self, query, slug, project_id): def filter_projects(self, query, slug, project_id, workspace_search):
fields = ["name"] fields = ["name", "identifier"]
q = Q() q = Q()
for field in fields: for field in fields:
q |= Q(**{f"{field}__icontains": query}) q |= Q(**{f"{field}__icontains": query})
@ -46,8 +46,8 @@ class GlobalSearchEndpoint(BaseAPIView):
.values("name", "id", "identifier", "workspace__slug") .values("name", "id", "identifier", "workspace__slug")
) )
def filter_issues(self, query, slug, project_id): def filter_issues(self, query, slug, project_id, workspace_search):
fields = ["name", "sequence_id"] fields = ["name", "sequence_id", "project__identifier"]
q = Q() q = Q()
for field in fields: for field in fields:
if field == "sequence_id": if field == "sequence_id":
@ -56,111 +56,123 @@ class GlobalSearchEndpoint(BaseAPIView):
q |= Q(**{"sequence_id": sequence_id}) q |= Q(**{"sequence_id": sequence_id})
else: else:
q |= Q(**{f"{field}__icontains": query}) q |= Q(**{f"{field}__icontains": query})
return (
Issue.issue_objects.filter( issues = Issue.issue_objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
workspace__slug=slug, workspace__slug=slug,
project_id=project_id,
)
.distinct()
.values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"workspace__slug",
)
) )
def filter_cycles(self, query, slug, project_id): if workspace_search == "false" and project_id:
issues = issues.filter(project_id=project_id)
return issues.distinct().values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"workspace__slug",
)
def filter_cycles(self, query, slug, project_id, workspace_search):
fields = ["name"] fields = ["name"]
q = Q() q = Q()
for field in fields: for field in fields:
q |= Q(**{f"{field}__icontains": query}) q |= Q(**{f"{field}__icontains": query})
return (
Cycle.objects.filter( cycles = Cycle.objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
workspace__slug=slug, workspace__slug=slug,
project_id=project_id,
)
.distinct()
.values(
"name",
"id",
"project_id",
"workspace__slug",
)
) )
def filter_modules(self, query, slug, project_id): if workspace_search == "false" and project_id:
cycles = cycles.filter(project_id=project_id)
return cycles.distinct().values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)
def filter_modules(self, query, slug, project_id, workspace_search):
fields = ["name"] fields = ["name"]
q = Q() q = Q()
for field in fields: for field in fields:
q |= Q(**{f"{field}__icontains": query}) q |= Q(**{f"{field}__icontains": query})
return (
Module.objects.filter( modules = Module.objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
workspace__slug=slug, workspace__slug=slug,
project_id=project_id,
)
.distinct()
.values(
"name",
"id",
"project_id",
"workspace__slug",
)
) )
def filter_pages(self, query, slug, project_id): if workspace_search == "false" and project_id:
modules = modules.filter(project_id=project_id)
return modules.distinct().values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)
def filter_pages(self, query, slug, project_id, workspace_search):
fields = ["name"] fields = ["name"]
q = Q() q = Q()
for field in fields: for field in fields:
q |= Q(**{f"{field}__icontains": query}) q |= Q(**{f"{field}__icontains": query})
return (
Page.objects.filter( pages = Page.objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
workspace__slug=slug, workspace__slug=slug,
project_id=project_id,
)
.distinct()
.values(
"name",
"id",
"project_id",
"workspace__slug",
)
) )
def filter_views(self, query, slug, project_id): if workspace_search == "false" and project_id:
pages = pages.filter(project_id=project_id)
return pages.distinct().values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)
def filter_views(self, query, slug, project_id, workspace_search):
fields = ["name"] fields = ["name"]
q = Q() q = Q()
for field in fields: for field in fields:
q |= Q(**{f"{field}__icontains": query}) q |= Q(**{f"{field}__icontains": query})
return (
IssueView.objects.filter( issue_views = IssueView.objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
workspace__slug=slug, workspace__slug=slug,
project_id=project_id,
)
.distinct()
.values(
"name",
"id",
"project_id",
"workspace__slug",
)
) )
def get(self, request, slug, project_id): if workspace_search == "false" and project_id:
issue_views = issue_views.filter(project_id=project_id)
return issue_views.distinct().values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)
def get(self, request, slug):
try: try:
query = request.query_params.get("search", False) query = request.query_params.get("search", False)
workspace_search = request.query_params.get("workspace_search", "false")
project_id = request.query_params.get("project_id", False)
if not query: if not query:
return Response( return Response(
{ {
@ -191,7 +203,7 @@ class GlobalSearchEndpoint(BaseAPIView):
for model in MODELS_MAPPER.keys(): for model in MODELS_MAPPER.keys():
func = MODELS_MAPPER.get(model, None) func = MODELS_MAPPER.get(model, None)
results[model] = func(query, slug, project_id) results[model] = func(query, slug, project_id, workspace_search)
return Response({"results": results}, status=status.HTTP_200_OK) return Response({"results": results}, status=status.HTTP_200_OK)
except Exception as e: except Exception as e:
@ -206,6 +218,7 @@ class IssueSearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id): def get(self, request, slug, project_id):
try: try:
query = request.query_params.get("search", False) query = request.query_params.get("search", False)
workspace_search = request.query_params.get("workspace_search", "false")
parent = request.query_params.get("parent", "false") parent = request.query_params.get("parent", "false")
blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false") blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false")
cycle = request.query_params.get("cycle", "false") cycle = request.query_params.get("cycle", "false")
@ -216,10 +229,12 @@ class IssueSearchEndpoint(BaseAPIView):
issues = Issue.issue_objects.filter( issues = Issue.issue_objects.filter(
workspace__slug=slug, workspace__slug=slug,
project_id=project_id,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
) )
if workspace_search == "false":
issues = issues.filter(project_id=project_id)
if query: if query:
issues = search_issues(query, issues) issues = search_issues(query, issues)
@ -251,12 +266,12 @@ class IssueSearchEndpoint(BaseAPIView):
if module == "true": if module == "true":
issues = issues.exclude(issue_module__isnull=False) issues = issues.exclude(issue_module__isnull=False)
return Response( return Response(
issues.values( issues.values(
"name", "name",
"id", "id",
"sequence_id", "sequence_id",
"project__name",
"project__identifier", "project__identifier",
"project_id", "project_id",
"workspace__slug", "workspace__slug",

View File

@ -13,12 +13,18 @@ from django.core.exceptions import ValidationError
from django.core.validators import validate_email from django.core.validators import validate_email
from django.contrib.sites.shortcuts import get_current_site from django.contrib.sites.shortcuts import get_current_site
from django.db.models import ( from django.db.models import (
CharField, Prefetch,
Count,
OuterRef, OuterRef,
Func, Func,
F, F,
Q, Q,
Count,
Case,
Value,
CharField,
When,
Max,
IntegerField,
) )
from django.db.models.functions import ExtractWeek, Cast, ExtractDay from django.db.models.functions import ExtractWeek, Cast, ExtractDay
from django.db.models.fields import DateField from django.db.models.fields import DateField
@ -39,6 +45,8 @@ from plane.api.serializers import (
UserLiteSerializer, UserLiteSerializer,
ProjectMemberSerializer, ProjectMemberSerializer,
WorkspaceThemeSerializer, WorkspaceThemeSerializer,
IssueActivitySerializer,
IssueLiteSerializer,
) )
from plane.api.views.base import BaseAPIView from plane.api.views.base import BaseAPIView
from . import BaseViewSet from . import BaseViewSet
@ -60,9 +68,23 @@ from plane.db.models import (
PageFavorite, PageFavorite,
Page, Page,
IssueViewFavorite, IssueViewFavorite,
IssueLink,
IssueAttachment,
IssueSubscriber,
Project,
Label,
WorkspaceMember,
CycleIssue,
)
from plane.api.permissions import (
WorkSpaceBasePermission,
WorkSpaceAdminPermission,
WorkspaceEntityPermission,
WorkspaceViewerPermission,
) )
from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission
from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.bgtasks.workspace_invitation_task import workspace_invitation
from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class WorkSpaceViewSet(BaseViewSet): class WorkSpaceViewSet(BaseViewSet):
@ -597,6 +619,19 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Check for the only member in the workspace
if (
workspace_member.role == 20
and WorkspaceMember.objects.filter(
workspace__slug=slug, role=20
).count()
== 1
):
return Response(
{"error": "Cannot delete the only Admin for the workspace"},
status=status.HTTP_400_BAD_REQUEST,
)
# Delete the user also from all the projects # Delete the user also from all the projects
ProjectMember.objects.filter( ProjectMember.objects.filter(
workspace__slug=slug, member=workspace_member.member workspace__slug=slug, member=workspace_member.member
@ -1009,3 +1044,391 @@ class WorkspaceThemeViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
filters = issue_filters(request.query_params, "GET")
state_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
)
.filter(**filters)
.annotate(state_group=F("state__group"))
.values("state_group")
.annotate(state_count=Count("state_group"))
.order_by("state_group")
)
priority_order = ["urgent", "high", "medium", "low", None]
priority_distribution = (
Issue.objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
)
.filter(**filters)
.values("priority")
.annotate(priority_count=Count("priority"))
.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
default=Value(len(priority_order)),
output_field=IntegerField(),
)
)
.order_by("priority_order")
)
created_issues = (
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
created_by_id=user_id,
)
.filter(**filters)
.count()
)
assigned_issues_count = (
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
)
.filter(**filters)
.count()
)
pending_issues_count = (
Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
)
.filter(**filters)
.count()
)
completed_issues_count = (
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
state__group="completed",
project__project_projectmember__member=request.user,
)
.filter(**filters)
.count()
)
subscribed_issues_count = (
IssueSubscriber.objects.filter(
workspace__slug=slug,
subscriber_id=user_id,
project__project_projectmember__member=request.user,
)
.filter(**filters)
.count()
)
upcoming_cycles = CycleIssue.objects.filter(
workspace__slug=slug,
cycle__start_date__gt=timezone.now().date(),
issue__assignees__in=[user_id,]
).values("cycle__name", "cycle__id", "cycle__project_id")
present_cycle = CycleIssue.objects.filter(
workspace__slug=slug,
cycle__start_date__lt=timezone.now().date(),
cycle__end_date__gt=timezone.now().date(),
issue__assignees__in=[user_id,]
).values("cycle__name", "cycle__id", "cycle__project_id")
return Response(
{
"state_distribution": state_distribution,
"priority_distribution": priority_distribution,
"created_issues": created_issues,
"assigned_issues": assigned_issues_count,
"completed_issues": completed_issues_count,
"pending_issues": pending_issues_count,
"subscribed_issues": subscribed_issues_count,
"present_cycles": present_cycle,
"upcoming_cycles": upcoming_cycles,
}
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceUserActivityEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug, user_id):
try:
projects = request.query_params.getlist("project", [])
queryset = IssueActivity.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
actor=user_id,
).select_related("actor", "workspace", "issue", "project")
if projects:
queryset = queryset.filter(project__in=projects)
return self.paginate(
request=request,
queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer(
issue_activities, many=True
).data,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceUserProfileEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
user_data = User.objects.get(pk=user_id)
requesting_workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user)
projects = []
if requesting_workspace_member.role >= 10:
projects = (
Project.objects.filter(
workspace__slug=slug,
project_projectmember__member=request.user,
)
.annotate(
created_issues=Count(
"project_issue", filter=Q(project_issue__created_by_id=user_id)
)
)
.annotate(
assigned_issues=Count(
"project_issue",
filter=Q(project_issue__assignees__in=[user_id]),
)
)
.annotate(
completed_issues=Count(
"project_issue",
filter=Q(
project_issue__completed_at__isnull=False,
project_issue__assignees__in=[user_id],
),
)
)
.annotate(
pending_issues=Count(
"project_issue",
filter=Q(
project_issue__state__group__in=[
"backlog",
"unstarted",
"started",
],
project_issue__assignees__in=[user_id],
),
)
)
.values(
"id",
"name",
"identifier",
"emoji",
"icon_prop",
"created_issues",
"assigned_issues",
"completed_issues",
"pending_issues",
)
)
return Response(
{
"project_data": projects,
"user_data": {
"email": user_data.email,
"first_name": user_data.first_name,
"last_name": user_data.last_name,
"avatar": user_data.avatar,
"cover_image": user_data.cover_image,
"date_joined": user_data.date_joined,
"user_timezone": user_data.user_timezone,
},
},
status=status.HTTP_200_OK,
)
except WorkspaceMember.DoesNotExist:
return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug, user_id):
try:
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.filter(
Q(assignees__in=[user_id])
| Q(created_by_id=user_id)
| Q(issue_subscribers__subscriber_id=user_id),
workspace__slug=slug,
project__project_projectmember__member=request.user,
)
.filter(**filters)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by("-created_at")
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
return Response(
group_results(issues, group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceLabelsEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug):
try:
labels = Label.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
).values("parent", "name", "color", "id", "project_id", "workspace__slug")
return Response(labels, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -70,7 +70,7 @@ def track_parent(
issue_id=issue_id, issue_id=issue_id,
actor=actor, actor=actor,
verb="updated", verb="updated",
old_value=f"{project.identifier}-{old_parent.sequence_id}", old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}",
new_value=None, new_value=None,
field="parent", field="parent",
project=project, project=project,
@ -88,10 +88,10 @@ def track_parent(
issue_id=issue_id, issue_id=issue_id,
actor=actor, actor=actor,
verb="updated", verb="updated",
old_value=f"{project.identifier}-{old_parent.sequence_id}" old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}"
if old_parent is not None if old_parent is not None
else None, else None,
new_value=f"{project.identifier}-{new_parent.sequence_id}", new_value=f"{new_parent.project.identifier}-{new_parent.sequence_id}",
field="parent", field="parent",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
@ -376,7 +376,7 @@ def track_assignees(
verb="updated", verb="updated",
old_value=assignee.email, old_value=assignee.email,
new_value="", new_value="",
field="assignee", field="assignees",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} removed assignee {assignee.email}", comment=f"{actor.email} removed assignee {assignee.email}",
@ -415,11 +415,11 @@ def track_blocks(
actor=actor, actor=actor,
verb="updated", verb="updated",
old_value="", old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}", new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field="blocks", field="blocks",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} added blocking issue {project.identifier}-{issue.sequence_id}", comment=f"{actor.email} added blocking issue {issue.project.identifier}-{issue.sequence_id}",
new_identifier=issue.id, new_identifier=issue.id,
) )
) )
@ -436,12 +436,12 @@ def track_blocks(
issue_id=issue_id, issue_id=issue_id,
actor=actor, actor=actor,
verb="updated", verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}", old_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value="", new_value="",
field="blocks", field="blocks",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} removed blocking issue {project.identifier}-{issue.sequence_id}", comment=f"{actor.email} removed blocking issue {issue.project.identifier}-{issue.sequence_id}",
old_identifier=issue.id, old_identifier=issue.id,
) )
) )
@ -477,11 +477,11 @@ def track_blockings(
actor=actor, actor=actor,
verb="updated", verb="updated",
old_value="", old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}", new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field="blocking", field="blocking",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} added blocked by issue {project.identifier}-{issue.sequence_id}", comment=f"{actor.email} added blocked by issue {issue.project.identifier}-{issue.sequence_id}",
new_identifier=issue.id, new_identifier=issue.id,
) )
) )
@ -498,12 +498,12 @@ def track_blockings(
issue_id=issue_id, issue_id=issue_id,
actor=actor, actor=actor,
verb="updated", verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}", old_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value="", new_value="",
field="blocking", field="blocking",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}", comment=f"{actor.email} removed blocked by issue {issue.project.identifier}-{issue.sequence_id}",
old_identifier=issue.id, old_identifier=issue.id,
) )
) )
@ -959,6 +959,11 @@ def update_link_activity(
def delete_link_activity( def delete_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities requested_data, current_instance, issue_id, project, actor, issue_activities
): ):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
issue_id=issue_id, issue_id=issue_id,
@ -968,6 +973,8 @@ def delete_link_activity(
verb="deleted", verb="deleted",
actor=actor, actor=actor,
field="link", field="link",
old_value=current_instance.get("url", ""),
new_value=""
) )
) )
@ -989,7 +996,7 @@ def create_attachment_activity(
verb="created", verb="created",
actor=actor, actor=actor,
field="attachment", field="attachment",
new_value=current_instance.get("access", ""), new_value=current_instance.get("asset", ""),
new_identifier=current_instance.get("id", None), new_identifier=current_instance.get("id", None),
) )
) )
@ -1034,11 +1041,14 @@ def issue_activity(
"module.activity.created", "module.activity.created",
"module.activity.deleted", "module.activity.deleted",
]: ]:
issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first() issue = Issue.objects.filter(pk=issue_id).first()
if issue is not None: if issue is not None:
issue.updated_at = timezone.now() try:
issue.save(update_fields=["updated_at"]) issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
except Exception as e:
pass
if subscriber: if subscriber:
# add the user to issue subscriber # add the user to issue subscriber
@ -1122,7 +1132,7 @@ def issue_activity(
issue_subscribers = issue_subscribers + issue_assignees issue_subscribers = issue_subscribers + issue_assignees
issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first() issue = Issue.objects.filter(pk=issue_id).first()
# Add bot filtering # Add bot filtering
if ( if (
@ -1149,7 +1159,7 @@ def issue_activity(
"issue": { "issue": {
"id": str(issue_id), "id": str(issue_id),
"name": str(issue.name), "name": str(issue.name),
"identifier": str(project.identifier), "identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id, "sequence_id": issue.sequence_id,
"state_name": issue.state.name, "state_name": issue.state.name,
"state_group": issue.state.group, "state_group": issue.state.group,

View File

@ -0,0 +1,97 @@
# Generated by Django 4.2.3 on 2023-07-23 16:33
import random
from django.db import migrations, models
import plane.db.models.workspace
def rename_field(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
for obj in Model.objects.filter(field="assignee"):
obj.field = "assignees"
updated_activity.append(obj)
Model.objects.bulk_update(updated_activity, ["field"], batch_size=100)
def update_workspace_member_props(apps, schema_editor):
Model = apps.get_model("db", "WorkspaceMember")
updated_workspace_member = []
for obj in Model.objects.all():
if obj.view_props is None:
obj.view_props = {
"filters": {"type": None},
"groupByProperty": None,
"issueView": "list",
"orderBy": "-created_at",
"properties": {
"assignee": True,
"due_date": True,
"key": True,
"labels": True,
"priority": True,
"state": True,
"sub_issue_count": True,
"attachment_count": True,
"link": True,
"estimate": True,
"created_on": True,
"updated_on": True,
},
"showEmptyGroups": True,
}
else:
current_view_props = obj.view_props
obj.view_props = {
"filters": {"type": None},
"groupByProperty": None,
"issueView": "list",
"orderBy": "-created_at",
"showEmptyGroups": True,
"properties": current_view_props,
}
updated_workspace_member.append(obj)
Model.objects.bulk_update(updated_workspace_member, ["view_props"], batch_size=100)
def update_project_member_sort_order(apps, schema_editor):
Model = apps.get_model("db", "ProjectMember")
updated_project_members = []
for obj in Model.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_project_members.append(obj)
Model.objects.bulk_update(updated_project_members, ["sort_order"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
("db", "0038_auto_20230720_1505"),
]
operations = [
migrations.RunPython(rename_field),
migrations.RunPython(update_workspace_member_props),
migrations.AlterField(
model_name='workspacemember',
name='view_props',
field=models.JSONField(default=plane.db.models.workspace.get_default_props),
),
migrations.AddField(
model_name='workspacemember',
name='default_props',
field=models.JSONField(default=plane.db.models.workspace.get_default_props),
),
migrations.AddField(
model_name='projectmember',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.RunPython(update_project_member_sort_order),
]

View File

@ -0,0 +1,81 @@
# Generated by Django 4.2.3 on 2023-08-01 06:02
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.project
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0039_auto_20230723_2203'),
]
operations = [
migrations.AddField(
model_name='projectmember',
name='preferences',
field=models.JSONField(default=plane.db.models.project.get_default_preferences),
),
migrations.AddField(
model_name='user',
name='cover_image',
field=models.URLField(blank=True, max_length=800, null=True),
),
migrations.CreateModel(
name='IssueReaction',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('reaction', models.CharField(max_length=20)),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_reactions', to=settings.AUTH_USER_MODEL)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_reactions', to='db.issue')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Reaction',
'verbose_name_plural': 'Issue Reactions',
'db_table': 'issue_reactions',
'ordering': ('-created_at',),
'unique_together': {('issue', 'actor', 'reaction')},
},
),
migrations.CreateModel(
name='CommentReaction',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('reaction', models.CharField(max_length=20)),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_reactions', to=settings.AUTH_USER_MODEL)),
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_reactions', to='db.issuecomment')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Comment Reaction',
'verbose_name_plural': 'Comment Reactions',
'db_table': 'comment_reactions',
'ordering': ('-created_at',),
'unique_together': {('comment', 'actor', 'reaction')},
},
),
migrations.AlterField(
model_name='project',
name='identifier',
field=models.CharField(max_length=12, verbose_name='Project Identifier'),
),
migrations.AlterField(
model_name='projectidentifier',
name='name',
field=models.CharField(max_length=12),
),
]

View File

@ -34,6 +34,8 @@ from .issue import (
IssueSequence, IssueSequence,
IssueAttachment, IssueAttachment,
IssueSubscriber, IssueSubscriber,
IssueReaction,
CommentReaction,
) )
from .asset import FileAsset from .asset import FileAsset

View File

@ -209,7 +209,7 @@ class IssueAssignee(ProjectBaseModel):
class IssueLink(ProjectBaseModel): class IssueLink(ProjectBaseModel):
title = models.CharField(max_length=255, null=True) title = models.CharField(max_length=255, null=True, blank=True)
url = models.URLField() url = models.URLField()
issue = models.ForeignKey( issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="issue_link" "db.Issue", on_delete=models.CASCADE, related_name="issue_link"
@ -424,6 +424,49 @@ class IssueSubscriber(ProjectBaseModel):
return f"{self.issue.name} {self.subscriber.email}" return f"{self.issue.name} {self.subscriber.email}"
class IssueReaction(ProjectBaseModel):
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_reactions",
)
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_reactions")
reaction = models.CharField(max_length=20)
class Meta:
unique_together = ["issue", "actor", "reaction"]
verbose_name = "Issue Reaction"
verbose_name_plural = "Issue Reactions"
db_table = "issue_reactions"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
class CommentReaction(ProjectBaseModel):
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="comment_reactions",
)
comment = models.ForeignKey(IssueComment, on_delete=models.CASCADE, related_name="comment_reactions")
reaction = models.CharField(max_length=20)
class Meta:
unique_together = ["comment", "actor", "reaction"]
verbose_name = "Comment Reaction"
verbose_name_plural = "Comment Reactions"
db_table = "comment_reactions"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
# TODO: Find a better method to save the model # TODO: Find a better method to save the model
@receiver(post_save, sender=Issue) @receiver(post_save, sender=Issue)
def create_issue_sequence(sender, instance, created, **kwargs): def create_issue_sequence(sender, instance, created, **kwargs):

View File

@ -31,6 +31,13 @@ def get_default_props():
"showEmptyGroups": True, "showEmptyGroups": True,
} }
def get_default_preferences():
return {
"pages": {
"block_display": True
}
}
class Project(BaseModel): class Project(BaseModel):
NETWORK_CHOICES = ((0, "Secret"), (2, "Public")) NETWORK_CHOICES = ((0, "Secret"), (2, "Public"))
@ -47,7 +54,7 @@ class Project(BaseModel):
"db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project" "db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project"
) )
identifier = models.CharField( identifier = models.CharField(
max_length=5, max_length=12,
verbose_name="Project Identifier", verbose_name="Project Identifier",
) )
default_assignee = models.ForeignKey( default_assignee = models.ForeignKey(
@ -147,6 +154,21 @@ class ProjectMember(ProjectBaseModel):
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
view_props = models.JSONField(default=get_default_props) view_props = models.JSONField(default=get_default_props)
default_props = models.JSONField(default=get_default_props) default_props = models.JSONField(default=get_default_props)
preferences = models.JSONField(default=get_default_preferences)
sort_order = models.FloatField(default=65535)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = ProjectMember.objects.filter(
workspace_id=self.project.workspace_id, member=self.member
).aggregate(smallest=models.Min("sort_order"))["smallest"]
# Project ordering
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
super(ProjectMember, self).save(*args, **kwargs)
class Meta: class Meta:
unique_together = ["project", "member"] unique_together = ["project", "member"]
@ -168,7 +190,7 @@ class ProjectIdentifier(AuditModel):
project = models.OneToOneField( project = models.OneToOneField(
Project, on_delete=models.CASCADE, related_name="project_identifier" Project, on_delete=models.CASCADE, related_name="project_identifier"
) )
name = models.CharField(max_length=10) name = models.CharField(max_length=12)
class Meta: class Meta:
unique_together = ["name", "workspace"] unique_together = ["name", "workspace"]

View File

@ -38,6 +38,7 @@ class User(AbstractBaseUser, PermissionsMixin):
first_name = models.CharField(max_length=255, blank=True) first_name = models.CharField(max_length=255, blank=True)
last_name = models.CharField(max_length=255, blank=True) last_name = models.CharField(max_length=255, blank=True)
avatar = models.CharField(max_length=255, blank=True) avatar = models.CharField(max_length=255, blank=True)
cover_image = models.URLField(blank=True, null=True, max_length=800)
# tracking metrics # tracking metrics
date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Created At") date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Created At")

View File

@ -14,6 +14,30 @@ ROLE_CHOICES = (
) )
def get_default_props():
return {
"filters": {"type": None},
"groupByProperty": None,
"issueView": "list",
"orderBy": "-created_at",
"properties": {
"assignee": True,
"due_date": True,
"key": True,
"labels": True,
"priority": True,
"state": True,
"sub_issue_count": True,
"attachment_count": True,
"link": True,
"estimate": True,
"created_on": True,
"updated_on": True,
},
"showEmptyGroups": True,
}
class Workspace(BaseModel): class Workspace(BaseModel):
name = models.CharField(max_length=80, verbose_name="Workspace Name") name = models.CharField(max_length=80, verbose_name="Workspace Name")
logo = models.URLField(verbose_name="Logo", blank=True, null=True) logo = models.URLField(verbose_name="Logo", blank=True, null=True)
@ -47,7 +71,8 @@ class WorkspaceMember(BaseModel):
) )
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
company_role = models.TextField(null=True, blank=True) company_role = models.TextField(null=True, blank=True)
view_props = models.JSONField(null=True, blank=True) view_props = models.JSONField(default=get_default_props)
default_props = models.JSONField(default=get_default_props)
class Meta: class Meta:
unique_together = ["workspace", "member"] unique_together = ["workspace", "member"]

View File

@ -12,6 +12,18 @@ def filter_state(params, filter, method):
return filter return filter
def filter_state_group(params, filter, method):
if method == "GET":
state_group = params.get("state_group").split(",")
if len(state_group) and "" not in state_group:
filter["state__group__in"] = state_group
else:
if params.get("state_group", None) and len(params.get("state_group")):
filter["state__group__in"] = params.get("state_group")
return filter
def filter_estimate_point(params, filter, method): def filter_estimate_point(params, filter, method):
if method == "GET": if method == "GET":
estimate_points = params.get("estimate_point").split(",") estimate_points = params.get("estimate_point").split(",")
@ -212,6 +224,7 @@ def filter_issue_state_type(params, filter, method):
return filter return filter
def filter_project(params, filter, method): def filter_project(params, filter, method):
if method == "GET": if method == "GET":
projects = params.get("project").split(",") projects = params.get("project").split(",")
@ -268,11 +281,24 @@ def filter_sub_issue_toggle(params, filter, method):
return filter return filter
def filter_subscribed_issues(params, filter, method):
if method == "GET":
subscribers = params.get("subscriber").split(",")
if len(subscribers) and "" not in subscribers:
filter["issue_subscribers__subscriber_id__in"] = subscribers
else:
if params.get("subscriber", None) and len(params.get("subscriber")):
filter["issue_subscribers__subscriber_id__in"] = params.get("subscriber")
return filter
def issue_filters(query_params, method): def issue_filters(query_params, method):
filter = dict() filter = dict()
print(query_params)
ISSUE_FILTER = { ISSUE_FILTER = {
"state": filter_state, "state": filter_state,
"state_group": filter_state_group,
"estimate_point": filter_estimate_point, "estimate_point": filter_estimate_point,
"priority": filter_priority, "priority": filter_priority,
"parent": filter_parent, "parent": filter_parent,
@ -291,6 +317,7 @@ def issue_filters(query_params, method):
"module": filter_module, "module": filter_module,
"inbox_status": filter_inbox_status, "inbox_status": filter_inbox_status,
"sub_issue": filter_sub_issue_toggle, "sub_issue": filter_sub_issue_toggle,
"subscriber": filter_subscribed_issues,
} }
for key, value in ISSUE_FILTER.items(): for key, value in ISSUE_FILTER.items():

View File

@ -89,8 +89,8 @@
</tr> </tr>
<tr> <tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;"> <td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<!--[if mso]> <!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1"> <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href={{magic_url}} style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<w:anchorlock/> <w:anchorlock/>
<div style="display:none;"> <div style="display:none;">
<center class="default-button"> <center class="default-button">
@ -98,11 +98,11 @@
</center> </center>
</div> </div>
</v:roundrect> </v:roundrect>
<![endif]--> <!--[if !mso]><!-- --> <![endif]--> <!--[if !mso]><!-- -->
<a href={{magic_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;"> <a href={{magic_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;">
<p style="margin: 0;"><span style="color: #3F76FF; font-size: 14px;">Open Plane</span></p> <p style="margin: 0;"><span style="color: #3F76FF; font-size: 14px;">Open Plane</span></p>
</a> </a>
<!--<![endif]--> <!--<![endif]-->
</td> </td>
</tr> </tr>
<tr class="nl2go-responsive-hide"> <tr class="nl2go-responsive-hide">
@ -364,4 +364,4 @@
</tr> </tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -90,8 +90,8 @@
</tr> </tr>
<tr> <tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;"> <td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<!--[if mso]> <!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1"> <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href={{invitation_url}} style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<w:anchorlock/> <w:anchorlock/>
<div style="display:none;"> <div style="display:none;">
<center class="default-button"> <center class="default-button">
@ -99,11 +99,11 @@
</center> </center>
</div> </div>
</v:roundrect> </v:roundrect>
<![endif]--> <!--[if !mso]><!-- --> <![endif]--> <!--[if !mso]><!-- -->
<a href={{invitation_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;"> <a href={{invitation_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;">
<p style="margin: 0;"><span style="color: #3F76FF;">Accept the invite</span></p> <p style="margin: 0;"><span style="color: #3F76FF;">Accept the invite</span></p>
</a> </a>
<!--<![endif]--> <!--<![endif]-->
</td> </td>
</tr> </tr>
<tr class="nl2go-responsive-hide"> <tr class="nl2go-responsive-hide">
@ -346,4 +346,4 @@
</tr> </tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -90,8 +90,8 @@
</tr> </tr>
<tr> <tr>
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;"> <td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
<!--[if mso]> <!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1"> <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href={{invitation_url}} style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
<w:anchorlock/> <w:anchorlock/>
<div style="display:none;"> <div style="display:none;">
<center class="default-button"> <center class="default-button">
@ -99,11 +99,11 @@
</center> </center>
</div> </div>
</v:roundrect> </v:roundrect>
<![endif]--> <!--[if !mso]><!-- --> <![endif]--> <!--[if !mso]><!-- -->
<a href={{invitation_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;"> <a href={{invitation_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;">
<p style="margin: 0;"><span style="color: #3F76FF;">Accept the invite</span></p> <p style="margin: 0;"><span style="color: #3F76FF;">Accept the invite</span></p>
</a> </a>
<!--<![endif]--> <!--<![endif]-->
</td> </td>
</tr> </tr>
<tr class="nl2go-responsive-hide"> <tr class="nl2go-responsive-hide">
@ -346,4 +346,4 @@
</tr> </tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -227,12 +227,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
</span> </span>
) : project.icon_prop ? ( ) : project.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0"> <div className="h-6 w-6 grid place-items-center flex-shrink-0">
<span {renderEmoji(project.icon_prop)}
style={{ color: project.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{project.icon_prop.name}
</span>
</div> </div>
) : ( ) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white"> <span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
@ -342,12 +337,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
</div> </div>
) : projectDetails?.icon_prop ? ( ) : projectDetails?.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0"> <div className="h-6 w-6 grid place-items-center flex-shrink-0">
<span {renderEmoji(projectDetails.icon_prop)}
style={{ color: projectDetails.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{projectDetails.icon_prop.name}
</span>
</div> </div>
) : ( ) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white"> <span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
@ -360,11 +350,8 @@ export const AnalyticsSidebar: React.FC<Props> = ({
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Network</h6> <h6 className="text-custom-text-200">Network</h6>
<span> <span>
{ {NETWORK_CHOICES.find((n) => n.key === projectDetails?.network)?.label ??
NETWORK_CHOICES[ ""}
`${projectDetails?.network}` as keyof typeof NETWORK_CHOICES
]
}
</span> </span>
</div> </div>
</div> </div>

View File

@ -28,7 +28,7 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
handleClose={() => setmonthModal(false)} handleClose={() => setmonthModal(false)}
handleChange={handleChange} handleChange={handleChange}
/> />
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-100 bg-custom-background-90"> <div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-300 bg-custom-background-90">
<div className="flex items-center justify-between gap-x-8 gap-y-2"> <div className="flex items-center justify-between gap-x-8 gap-y-2">
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<h4 className="text-lg font-semibold">Auto-archive closed issues</h4> <h4 className="text-lg font-semibold">Auto-archive closed issues</h4>

View File

@ -37,8 +37,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
? () => stateService.getStates(workspaceSlug as string, projectId as string) ? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null : null
); );
const states = getStatesList(stateGroups);
const states = getStatesList(stateGroups ?? {});
const options = states const options = states
?.filter((state) => state.group === "cancelled") ?.filter((state) => state.group === "cancelled")
@ -53,14 +52,14 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
), ),
})); }));
const multipleOptions = options.length > 1; const multipleOptions = (options ?? []).length > 1;
const defaultState = stateGroups && stateGroups.cancelled ? stateGroups.cancelled[0].id : null; const defaultState = stateGroups && stateGroups.cancelled ? stateGroups.cancelled[0].id : null;
const selectedOption = states?.find( const selectedOption = states?.find(
(s) => s.id === projectDetails?.default_state ?? defaultState (s) => s.id === projectDetails?.default_state ?? defaultState
); );
const currentDefaultState = states.find((s) => s.id === defaultState); const currentDefaultState = states?.find((s) => s.id === defaultState);
const initialValues: Partial<IProject> = { const initialValues: Partial<IProject> = {
close_in: 1, close_in: 1,
@ -77,7 +76,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
handleChange={handleChange} handleChange={handleChange}
/> />
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-100 bg-custom-background-90"> <div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-300 bg-custom-background-90">
<div className="flex items-center justify-between gap-x-8 gap-y-2 "> <div className="flex items-center justify-between gap-x-8 gap-y-2 ">
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<h4 className="text-lg font-semibold">Auto-close inactive issues</h4> <h4 className="text-lg font-semibold">Auto-close inactive issues</h4>

View File

@ -2,7 +2,6 @@ import * as React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
// icons // icons
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import { Icon } from "components/ui"; import { Icon } from "components/ui";
type BreadcrumbsProps = { type BreadcrumbsProps = {
@ -14,7 +13,7 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
return ( return (
<> <>
<div className="flex items-center"> <div className="flex items-center flex-grow w-full whitespace-nowrap overflow-hidden overflow-ellipsis">
<button <button
type="button" type="button"
className="group grid h-7 w-7 flex-shrink-0 cursor-pointer place-items-center rounded border border-custom-sidebar-border-200 text-center text-sm hover:bg-custom-sidebar-background-90" className="group grid h-7 w-7 flex-shrink-0 cursor-pointer place-items-center rounded border border-custom-sidebar-border-200 text-center text-sm hover:bg-custom-sidebar-background-90"
@ -35,22 +34,36 @@ type BreadcrumbItemProps = {
title: string; title: string;
link?: string; link?: string;
icon?: any; icon?: any;
linkTruncate?: boolean;
unshrinkTitle?: boolean;
}; };
const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) => ( const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({
title,
link,
icon,
linkTruncate = false,
unshrinkTitle = false,
}) => (
<> <>
{link ? ( {link ? (
<Link href={link}> <Link href={link}>
<a className="border-r-2 border-custom-sidebar-border-200 px-3 text-sm"> <a
<p className={`${icon ? "flex items-center gap-2" : ""}`}> className={`border-r-2 border-custom-sidebar-border-200 px-3 text-sm ${
linkTruncate ? "truncate" : ""
}`}
>
<p
className={`${linkTruncate ? "truncate" : ""}${icon ? "flex items-center gap-2" : ""}`}
>
{icon ?? null} {icon ?? null}
{title} {title}
</p> </p>
</a> </a>
</Link> </Link>
) : ( ) : (
<div className="max-w-64 px-3 text-sm"> <div className={`px-3 text-sm truncate ${unshrinkTitle ? "flex-shrink-0" : ""}`}>
<p className={`${icon ? "flex items-center gap-2" : ""}`}> <p className={`truncate ${icon ? "flex items-center gap-2" : ""}`}>
{icon} {icon}
<span className="break-words">{title}</span> <span className="break-words">{title}</span>
</p> </p>

View File

@ -0,0 +1,776 @@
import React, { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import workspaceService from "services/workspace.service";
import issuesService from "services/issues.service";
import inboxService from "services/inbox.service";
// hooks
import useProjectDetails from "hooks/use-project-details";
import useDebounce from "hooks/use-debounce";
import useUser from "hooks/use-user";
import useToast from "hooks/use-toast";
// components
import {
ChangeInterfaceTheme,
ChangeIssueAssignee,
ChangeIssuePriority,
ChangeIssueState,
commandGroups,
} from "components/command-palette";
// ui
import { Icon, Loader, ToggleSwitch, Tooltip } from "components/ui";
// icons
import { DiscordIcon, GithubIcon, SettingIcon } from "components/icons";
import { InboxIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IIssue, IWorkspaceSearchResults } from "types";
// fetch-keys
import { INBOX_LIST, ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
type Props = {
deleteIssue: () => void;
isPaletteOpen: boolean;
setIsPaletteOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPaletteOpen }) => {
const [placeholder, setPlaceholder] = useState("Type a command or search...");
const [resultsCount, setResultsCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [results, setResults] = useState<IWorkspaceSearchResults>({
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
});
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const [pages, setPages] = useState<string[]>([]);
const page = pages[pages.length - 1];
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { setToastAlert } = useToast();
const { user } = useUser();
const { projectDetails } = useProjectDetails();
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId
? () =>
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
: null
);
const { data: inboxList } = useSWR(
workspaceSlug && projectId ? INBOX_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => inboxService.getInboxes(workspaceSlug as string, projectId as string)
: null
);
const updateIssue = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
mutate(ISSUE_DETAILS(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId, user]
);
const handleIssueAssignees = (assignee: string) => {
if (!issueDetails) return;
setIsPaletteOpen(false);
const updatedAssignees = issueDetails.assignees ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees_list: updatedAssignees });
};
const redirect = (path: string) => {
setIsPaletteOpen(false);
router.push(path);
};
const createNewWorkspace = () => {
setIsPaletteOpen(false);
router.push("/create-workspace");
};
const copyIssueUrlToClipboard = useCallback(() => {
if (!router.query.issueId) return;
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
});
}, [router, setToastAlert]);
useEffect(
() => {
if (!workspaceSlug) return;
setIsLoading(true);
if (debouncedSearchTerm) {
setIsSearching(true);
workspaceService
.searchWorkspace(workspaceSlug as string, {
...(projectId ? { project_id: projectId.toString() } : {}),
search: debouncedSearchTerm,
workspace_search: !projectId ? true : isWorkspaceLevel,
})
.then((results) => {
setResults(results);
const count = Object.keys(results.results).reduce(
(accumulator, key) => (results.results as any)[key].length + accumulator,
0
);
setResultsCount(count);
})
.finally(() => {
setIsLoading(false);
setIsSearching(false);
});
} else {
setResults({
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
});
setIsLoading(false);
setIsSearching(false);
}
},
[debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes
);
if (!user) return null;
return (
<Transition.Root
show={isPaletteOpen}
afterLeave={() => {
setSearchTerm("");
}}
as={React.Fragment}
>
<Dialog as="div" className="relative z-30" onClose={() => setIsPaletteOpen(false)}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-custom-border-200 divide-opacity-10 rounded-xl border border-custom-border-200 bg-custom-background-100 shadow-2xl transition-all">
<Command
filter={(value, search) => {
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
return 0;
}}
onKeyDown={(e) => {
// when search is empty and page is undefined
// when user tries to close the modal with esc
if (e.key === "Escape" && !page && !searchTerm) {
setIsPaletteOpen(false);
}
// Escape goes to previous page
// Backspace goes to previous page when search is empty
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
e.preventDefault();
setPages((pages) => pages.slice(0, -1));
setPlaceholder("Type a command or search...");
}
}}
>
<div
className={`flex sm:items-center gap-4 p-3 pb-0 ${
issueDetails ? "flex-col sm:flex-row justify-between" : "justify-end"
}`}
>
{issueDetails && (
<div className="overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}{" "}
{issueDetails.name}
</div>
)}
{projectId && (
<Tooltip tooltipContent="Toggle workspace level search">
<div className="flex-shrink-0 self-end sm:self-center flex items-center gap-1 text-xs cursor-pointer">
<button
type="button"
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
className="flex-shrink-0"
>
Workspace Level
</button>
<ToggleSwitch
value={isWorkspaceLevel}
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
/>
</div>
</Tooltip>
)}
</div>
<div className="relative">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-custom-text-200"
aria-hidden="true"
/>
<Command.Input
className="w-full border-0 border-b border-custom-border-200 bg-transparent p-4 pl-11 text-custom-text-100 placeholder:text-custom-text-400 outline-none focus:ring-0 text-sm"
placeholder={placeholder}
value={searchTerm}
onValueChange={(e) => {
setSearchTerm(e);
}}
autoFocus
tabIndex={1}
/>
</div>
<Command.List className="max-h-96 overflow-scroll p-2">
{searchTerm !== "" && (
<h5 className="text-xs text-custom-text-100 mx-[3px] my-4">
Search results for{" "}
<span className="font-medium">
{'"'}
{searchTerm}
{'"'}
</span>{" "}
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
</h5>
)}
{!isLoading &&
resultsCount === 0 &&
searchTerm !== "" &&
debouncedSearchTerm !== "" && (
<div className="my-4 text-center text-custom-text-200">No results found.</div>
)}
{(isLoading || isSearching) && (
<Command.Loading>
<Loader className="space-y-3">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
</Command.Loading>
)}
{debouncedSearchTerm !== "" &&
Object.keys(results.results).map((key) => {
const section = (results.results as any)[key];
const currentSection = commandGroups[key];
if (section.length > 0) {
return (
<Command.Group key={key} heading={currentSection.title}>
{section.map((item: any) => (
<Command.Item
key={item.id}
onSelect={() => {
router.push(currentSection.path(item));
setIsPaletteOpen(false);
}}
value={`${key}-${item?.name}`}
className="focus:outline-none"
>
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
<Icon iconName={currentSection.icon} />
<p className="block flex-1 truncate">
{currentSection.itemName(item)}
</p>
</div>
</Command.Item>
))}
</Command.Group>
);
}
})}
{!page && (
<>
{issueId && (
<Command.Group heading="Issue actions">
<Command.Item
onSelect={() => {
setPlaceholder("Change state...");
setSearchTerm("");
setPages([...pages, "change-issue-state"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="grid_view" />
Change state...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change priority...");
setSearchTerm("");
setPages([...pages, "change-issue-priority"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="bar_chart" />
Change priority...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Assign to...");
setSearchTerm("");
setPages([...pages, "change-issue-assignee"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="group" />
Assign to...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
handleIssueAssignees(user.id);
setSearchTerm("");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
{issueDetails?.assignees.includes(user.id) ? (
<>
<Icon iconName="person_remove" />
Un-assign from me
</>
) : (
<>
<Icon iconName="person_add" />
Assign to me
</>
)}
</div>
</Command.Item>
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="delete" />
Delete issue
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
copyIssueUrlToClipboard();
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="link" />
Copy issue URL
</div>
</Command.Item>
</Command.Group>
)}
<Command.Group heading="Issue">
<Command.Item
onSelect={() => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
}}
className="focus:bg-custom-background-80"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="stack" />
Create new issue
</div>
<kbd>C</kbd>
</Command.Item>
</Command.Group>
{workspaceSlug && (
<Command.Group heading="Project">
<Command.Item
onSelect={() => {
const e = new KeyboardEvent("keydown", {
key: "p",
});
document.dispatchEvent(e);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="create_new_folder" />
Create new project
</div>
<kbd>P</kbd>
</Command.Item>
</Command.Group>
)}
{projectId && (
<>
<Command.Group heading="Cycle">
<Command.Item
onSelect={() => {
const e = new KeyboardEvent("keydown", {
key: "q",
});
document.dispatchEvent(e);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="contrast" />
Create new cycle
</div>
<kbd>Q</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Module">
<Command.Item
onSelect={() => {
const e = new KeyboardEvent("keydown", {
key: "m",
});
document.dispatchEvent(e);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="dataset" />
Create new module
</div>
<kbd>M</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="View">
<Command.Item
onSelect={() => {
const e = new KeyboardEvent("keydown", {
key: "v",
});
document.dispatchEvent(e);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="photo_filter" />
Create new view
</div>
<kbd>V</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Page">
<Command.Item
onSelect={() => {
const e = new KeyboardEvent("keydown", {
key: "d",
});
document.dispatchEvent(e);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="article" />
Create new page
</div>
<kbd>D</kbd>
</Command.Item>
</Command.Group>
{projectDetails && projectDetails.inbox_view && (
<Command.Group heading="Inbox">
<Command.Item
onSelect={() =>
redirect(
`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`
)
}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<InboxIcon className="h-4 w-4" color="#6b7280" />
Open inbox
</div>
</Command.Item>
</Command.Group>
)}
</>
)}
<Command.Group heading="Workspace Settings">
<Command.Item
onSelect={() => {
setPlaceholder("Search workspace settings...");
setSearchTerm("");
setPages([...pages, "settings"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="settings" />
Search settings...
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Account">
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="create_new_folder" />
Create new workspace
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change interface theme...");
setSearchTerm("");
setPages([...pages, "change-interface-theme"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="settings" />
Change interface theme...
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Help">
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", {
key: "h",
});
document.dispatchEvent(e);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="rocket_launch" />
Open keyboard shortcuts
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open("https://docs.plane.so/", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="article" />
Open Plane documentation
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiscordIcon className="h-4 w-4" color="#6b7280" />
Join our Discord
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open(
"https://github.com/makeplane/plane/issues/new/choose",
"_blank"
);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<GithubIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Report a bug
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
(window as any).$crisp.push(["do", "chat:open"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Icon iconName="sms" />
Chat with us
</div>
</Command.Item>
</Command.Group>
</>
)}
{page === "settings" && workspaceSlug && (
<>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
General
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/members`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Members
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/billing`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Billing and Plans
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/integrations`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Integrations
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/import-export`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Import/Export
</div>
</Command.Item>
</>
)}
{page === "change-issue-state" && issueDetails && (
<ChangeIssueState
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
user={user}
/>
)}
{page === "change-issue-priority" && issueDetails && (
<ChangeIssuePriority
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
user={user}
/>
)}
{page === "change-issue-assignee" && issueDetails && (
<ChangeIssueAssignee
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
user={user}
/>
)}
{page === "change-interface-theme" && (
<ChangeInterfaceTheme setIsPaletteOpen={setIsPaletteOpen} />
)}
</Command.List>
</Command>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -1,53 +1,15 @@
import { useRouter } from "next/router";
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import useSWR, { mutate } from "swr";
// icons import { useRouter } from "next/router";
import {
ArrowRightIcon, import useSWR from "swr";
ChartBarIcon,
ChatBubbleOvalLeftEllipsisIcon,
DocumentTextIcon,
FolderPlusIcon,
InboxIcon,
LinkIcon,
MagnifyingGlassIcon,
RocketLaunchIcon,
Squares2X2Icon,
TrashIcon,
UserMinusIcon,
UserPlusIcon,
UsersIcon,
} from "@heroicons/react/24/outline";
import {
AssignmentClipboardIcon,
ContrastIcon,
DiscordIcon,
DocumentIcon,
GithubIcon,
LayerDiagonalIcon,
PeopleGroupIcon,
SettingIcon,
ViewListIcon,
} from "components/icons";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// cmdk
import { Command } from "cmdk";
// hooks // hooks
import useProjectDetails from "hooks/use-project-details";
import useTheme from "hooks/use-theme"; import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
import useDebounce from "hooks/use-debounce";
// components // components
import { import { CommandK, ShortcutsModal } from "components/command-palette";
ShortcutsModal,
ChangeIssueState,
ChangeIssuePriority,
ChangeIssueAssignee,
ChangeInterfaceTheme,
} from "components/command-palette";
import { BulkDeleteIssuesModal } from "components/core"; import { BulkDeleteIssuesModal } from "components/core";
import { CreateUpdateCycleModal } from "components/cycles"; import { CreateUpdateCycleModal } from "components/cycles";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
@ -55,22 +17,13 @@ import { CreateUpdateModuleModal } from "components/modules";
import { CreateProjectModal } from "components/project"; import { CreateProjectModal } from "components/project";
import { CreateUpdateViewModal } from "components/views"; import { CreateUpdateViewModal } from "components/views";
import { CreateUpdatePageModal } from "components/pages"; import { CreateUpdatePageModal } from "components/pages";
import { Spinner } from "components/ui";
// helpers // helpers
import { import { copyTextToClipboard } from "helpers/string.helper";
capitalizeFirstLetter,
copyTextToClipboard,
replaceUnderscoreIfSnakeCase,
} from "helpers/string.helper";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
import workspaceService from "services/workspace.service";
import inboxService from "services/inbox.service"; import inboxService from "services/inbox.service";
// types
import { IIssue, IWorkspaceSearchResults } from "types";
// fetch keys // fetch keys
import { INBOX_LIST, ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
export const CommandPalette: React.FC = () => { export const CommandPalette: React.FC = () => {
const [isPaletteOpen, setIsPaletteOpen] = useState(false); const [isPaletteOpen, setIsPaletteOpen] = useState(false);
@ -84,36 +37,15 @@ export const CommandPalette: React.FC = () => {
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false); const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [results, setResults] = useState<IWorkspaceSearchResults>({
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
});
const [resultsCount, setResultsCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const [placeholder, setPlaceholder] = React.useState("Type a command or search...");
const [pages, setPages] = React.useState<string[]>([]);
const page = pages[pages.length - 1];
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId, inboxId } = router.query; const { workspaceSlug, projectId, issueId, inboxId } = router.query;
const { user } = useUser(); const { user } = useUser();
const { projectDetails } = useProjectDetails();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { toggleCollapsed } = useTheme(); const { toggleCollapsed } = useTheme();
const { data: issueDetails } = useSWR<IIssue | undefined>( const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null, workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId workspaceSlug && projectId && issueId
? () => ? () =>
@ -121,59 +53,6 @@ export const CommandPalette: React.FC = () => {
: null : null
); );
const { data: inboxList } = useSWR(
workspaceSlug && projectId ? INBOX_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => inboxService.getInboxes(workspaceSlug as string, projectId as string)
: null
);
const updateIssue = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
mutate(ISSUE_DETAILS(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId, user]
);
const handleIssueAssignees = (assignee: string) => {
if (!issueDetails) return;
setIsPaletteOpen(false);
const updatedAssignees = issueDetails.assignees ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees_list: updatedAssignees });
};
const copyIssueUrlToClipboard = useCallback(() => { const copyIssueUrlToClipboard = useCallback(() => {
if (!router.query.issueId) return; if (!router.query.issueId) return;
@ -246,98 +125,17 @@ export const CommandPalette: React.FC = () => {
useEffect(() => { useEffect(() => {
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]); }, [handleKeyDown]);
useEffect(
() => {
if (!workspaceSlug || !projectId) return;
setIsLoading(true);
// this is done prevent subsequent api request
// or searchTerm has not been updated within last 500ms.
if (debouncedSearchTerm) {
setIsSearching(true);
workspaceService
.searchWorkspace(workspaceSlug as string, projectId as string, debouncedSearchTerm)
.then((results) => {
setResults(results);
const count = Object.keys(results.results).reduce(
(accumulator, key) => (results.results as any)[key].length + accumulator,
0
);
setResultsCount(count);
})
.finally(() => {
setIsLoading(false);
setIsSearching(false);
});
} else {
setResults({
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
});
setIsLoading(false);
setIsSearching(false);
}
},
[debouncedSearchTerm, workspaceSlug, projectId] // Only call effect if debounced search term changes
);
if (!user) return null; if (!user) return null;
const createNewWorkspace = () => {
setIsPaletteOpen(false);
router.push("/create-workspace");
};
const createNewProject = () => {
setIsPaletteOpen(false);
setIsProjectModalOpen(true);
};
const createNewIssue = () => {
setIsPaletteOpen(false);
setIsIssueModalOpen(true);
};
const createNewCycle = () => {
setIsPaletteOpen(false);
setIsCreateCycleModalOpen(true);
};
const createNewView = () => {
setIsPaletteOpen(false);
setIsCreateViewModalOpen(true);
};
const createNewPage = () => {
setIsPaletteOpen(false);
setIsCreateUpdatePageModalOpen(true);
};
const createNewModule = () => {
setIsPaletteOpen(false);
setIsCreateModuleModalOpen(true);
};
const deleteIssue = () => { const deleteIssue = () => {
setIsPaletteOpen(false); setIsPaletteOpen(false);
setDeleteIssueModal(true); setDeleteIssueModal(true);
}; };
const redirect = (path: string) => {
setIsPaletteOpen(false);
router.push(path);
};
return ( return (
<> <>
<ShortcutsModal isOpen={isShortcutsModalOpen} setIsOpen={setIsShortcutsModalOpen} /> <ShortcutsModal isOpen={isShortcutsModalOpen} setIsOpen={setIsShortcutsModalOpen} />
@ -390,538 +188,11 @@ export const CommandPalette: React.FC = () => {
setIsOpen={setIsBulkDeleteIssuesModalOpen} setIsOpen={setIsBulkDeleteIssuesModalOpen}
user={user} user={user}
/> />
<Transition.Root <CommandK
show={isPaletteOpen} deleteIssue={deleteIssue}
afterLeave={() => { isPaletteOpen={isPaletteOpen}
setSearchTerm(""); setIsPaletteOpen={setIsPaletteOpen}
}} />
as={React.Fragment}
>
<Dialog as="div" className="relative z-30" onClose={() => setIsPaletteOpen(false)}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-custom-border-200 divide-opacity-10 rounded-xl border border-custom-border-200 bg-custom-background-100 shadow-2xl transition-all">
<Command
filter={(value, search) => {
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
return 0;
}}
onKeyDown={(e) => {
// when search is empty and page is undefined
// when user tries to close the modal with esc
if (e.key === "Escape" && !page && !searchTerm) {
setIsPaletteOpen(false);
}
// Escape goes to previous page
// Backspace goes to previous page when search is empty
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
e.preventDefault();
setPages((pages) => pages.slice(0, -1));
setPlaceholder("Type a command or search...");
}
}}
>
{issueId && issueDetails && (
<div className="flex p-3">
<p className="overflow-hidden truncate rounded-md bg-custom-background-90 p-1 px-2 text-xs font-medium text-custom-text-200">
{issueDetails.project_detail?.identifier}-{issueDetails.sequence_id}{" "}
{issueDetails?.name}
</p>
</div>
)}
<div className="relative">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-custom-text-200"
aria-hidden="true"
/>
<Command.Input
className="w-full border-0 border-b border-custom-border-200 bg-transparent p-4 pl-11 text-custom-text-100 outline-none focus:ring-0 sm:text-sm"
placeholder={placeholder}
value={searchTerm}
onValueChange={(e) => {
setSearchTerm(e);
}}
autoFocus
/>
</div>
<Command.List className="max-h-96 overflow-scroll p-2">
{!isLoading &&
resultsCount === 0 &&
searchTerm !== "" &&
debouncedSearchTerm !== "" && (
<div className="my-4 text-center text-custom-text-200">
No results found.
</div>
)}
{(isLoading || isSearching) && (
<Command.Loading>
<div className="flex h-full w-full items-center justify-center py-8">
<Spinner />
</div>
</Command.Loading>
)}
{debouncedSearchTerm !== "" && (
<>
{Object.keys(results.results).map((key) => {
const section = (results.results as any)[key];
if (section.length > 0) {
return (
<Command.Group
heading={capitalizeFirstLetter(replaceUnderscoreIfSnakeCase(key))}
key={key}
>
{section.map((item: any) => {
let path = "";
let value = item.name;
let Icon: any = ArrowRightIcon;
if (key === "workspace") {
path = `/${item.slug}`;
Icon = FolderPlusIcon;
} else if (key == "project") {
path = `/${item.workspace__slug}/projects/${item.id}/issues`;
Icon = AssignmentClipboardIcon;
} else if (key === "issue") {
path = `/${item.workspace__slug}/projects/${item.project_id}/issues/${item.id}`;
// user can search id-num idnum or issue name
value = `${item.project__identifier}-${item.sequence_id} ${item.project__identifier}${item.sequence_id} ${item.name}`;
Icon = LayerDiagonalIcon;
} else if (key === "issue_view") {
path = `/${item.workspace__slug}/projects/${item.project_id}/views/${item.id}`;
Icon = ViewListIcon;
} else if (key === "module") {
path = `/${item.workspace__slug}/projects/${item.project_id}/modules/${item.id}`;
Icon = PeopleGroupIcon;
} else if (key === "page") {
path = `/${item.workspace__slug}/projects/${item.project_id}/pages/${item.id}`;
Icon = DocumentTextIcon;
} else if (key === "cycle") {
path = `/${item.workspace__slug}/projects/${item.project_id}/cycles/${item.id}`;
Icon = ContrastIcon;
}
return (
<Command.Item
key={item.id}
onSelect={() => {
router.push(path);
setIsPaletteOpen(false);
}}
value={value}
className="focus:outline-none"
>
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
<Icon
className="h-4 w-4 text-custom-text-200"
color="#6b7280"
/>
<p className="block flex-1 truncate">{item.name}</p>
</div>
</Command.Item>
);
})}
</Command.Group>
);
}
})}
</>
)}
{!page && (
<>
{issueId && (
<>
<Command.Item
onSelect={() => {
setPlaceholder("Change state...");
setSearchTerm("");
setPages([...pages, "change-issue-state"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Squares2X2Icon className="h-4 w-4 text-custom-text-200" />
Change state...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change priority...");
setSearchTerm("");
setPages([...pages, "change-issue-priority"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<ChartBarIcon className="h-4 w-4 text-custom-text-200" />
Change priority...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Assign to...");
setSearchTerm("");
setPages([...pages, "change-issue-assignee"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<UsersIcon className="h-4 w-4 text-custom-text-200" />
Assign to...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
handleIssueAssignees(user.id);
setSearchTerm("");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
{issueDetails?.assignees.includes(user.id) ? (
<>
<UserMinusIcon className="h-4 w-4 text-custom-text-200" />
Un-assign from me
</>
) : (
<>
<UserPlusIcon className="h-4 w-4 text-custom-text-200" />
Assign to me
</>
)}
</div>
</Command.Item>
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<TrashIcon className="h-4 w-4 text-custom-text-200" />
Delete issue
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
copyIssueUrlToClipboard();
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LinkIcon className="h-4 w-4 text-custom-text-200" />
Copy issue URL to clipboard
</div>
</Command.Item>
</>
)}
<Command.Group heading="Issue">
<Command.Item
onSelect={createNewIssue}
className="focus:bg-custom-background-80"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LayerDiagonalIcon className="h-4 w-4" color="#6b7280" />
Create new issue
</div>
<kbd>C</kbd>
</Command.Item>
</Command.Group>
{workspaceSlug && (
<Command.Group heading="Project">
<Command.Item
onSelect={createNewProject}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<AssignmentClipboardIcon className="h-4 w-4" color="#6b7280" />
Create new project
</div>
<kbd>P</kbd>
</Command.Item>
</Command.Group>
)}
{projectId && (
<>
<Command.Group heading="Cycle">
<Command.Item
onSelect={createNewCycle}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<ContrastIcon className="h-4 w-4" color="#6b7280" />
Create new cycle
</div>
<kbd>Q</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Module">
<Command.Item
onSelect={createNewModule}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<PeopleGroupIcon className="h-4 w-4" color="#6b7280" />
Create new module
</div>
<kbd>M</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="View">
<Command.Item onSelect={createNewView} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<ViewListIcon className="h-4 w-4" color="#6b7280" />
Create new view
</div>
<kbd>V</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Page">
<Command.Item onSelect={createNewPage} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<DocumentTextIcon className="h-4 w-4" color="#6b7280" />
Create new page
</div>
<kbd>D</kbd>
</Command.Item>
</Command.Group>
{projectDetails && projectDetails.inbox_view && (
<Command.Group heading="Inbox">
<Command.Item
onSelect={() =>
redirect(
`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`
)
}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<InboxIcon className="h-4 w-4" color="#6b7280" />
Open inbox
</div>
</Command.Item>
</Command.Group>
)}
</>
)}
<Command.Group heading="Workspace Settings">
<Command.Item
onSelect={() => {
setPlaceholder("Search workspace settings...");
setSearchTerm("");
setPages([...pages, "settings"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4" color="#6b7280" />
Search settings...
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Account">
<Command.Item
onSelect={createNewWorkspace}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FolderPlusIcon className="h-4 w-4 text-custom-text-200" />
Create new workspace
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change interface theme...");
setSearchTerm("");
setPages([...pages, "change-interface-theme"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Change interface theme...
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Help">
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", {
key: "h",
});
document.dispatchEvent(e);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<RocketLaunchIcon className="h-4 w-4 text-custom-text-200" />
Open keyboard shortcuts
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open("https://docs.plane.so/", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DocumentIcon className="h-4 w-4 text-custom-text-200" />
Open Plane documentation
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiscordIcon className="h-4 w-4" color="#6b7280" />
Join our Discord
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open(
"https://github.com/makeplane/plane/issues/new/choose",
"_blank"
);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<GithubIcon className="h-4 w-4" color="#6b7280" />
Report a bug
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
(window as any).$crisp.push(["do", "chat:open"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<ChatBubbleOvalLeftEllipsisIcon className="h-4 w-4 text-custom-text-200" />
Chat with us
</div>
</Command.Item>
</Command.Group>
</>
)}
{page === "settings" && workspaceSlug && (
<>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
General
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/members`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Members
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/billing`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Billing and Plans
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/integrations`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Integrations
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/import-export`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Import/Export
</div>
</Command.Item>
</>
)}
{page === "change-issue-state" && issueDetails && (
<>
<ChangeIssueState
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
user={user}
/>
</>
)}
{page === "change-issue-priority" && issueDetails && (
<ChangeIssuePriority
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
user={user}
/>
)}
{page === "change-issue-assignee" && issueDetails && (
<ChangeIssueAssignee
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
user={user}
/>
)}
{page === "change-interface-theme" && (
<ChangeInterfaceTheme setIsPaletteOpen={setIsPaletteOpen} />
)}
</Command.List>
</Command>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</> </>
); );
}; };

View File

@ -0,0 +1,95 @@
// types
import {
IWorkspaceDefaultSearchResult,
IWorkspaceIssueSearchResult,
IWorkspaceProjectSearchResult,
IWorkspaceSearchResult,
} from "types";
export const commandGroups: {
[key: string]: {
icon: string;
itemName: (item: any) => React.ReactNode;
path: (item: any) => string;
title: string;
};
} = {
cycle: {
icon: "contrast",
itemName: (cycle: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{cycle.project__identifier}</span>
{"- "}
{cycle.name}
</h6>
),
path: (cycle: IWorkspaceDefaultSearchResult) =>
`/${cycle?.workspace__slug}/projects/${cycle?.project_id}/cycles/${cycle?.id}`,
title: "Cycles",
},
issue: {
icon: "stack",
itemName: (issue: IWorkspaceIssueSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{issue.project__identifier}</span>
{"- "}
{issue.name}
</h6>
),
path: (issue: IWorkspaceIssueSearchResult) =>
`/${issue?.workspace__slug}/projects/${issue?.project_id}/issues/${issue?.id}`,
title: "Issues",
},
issue_view: {
icon: "photo_filter",
itemName: (view: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{view.project__identifier}</span>
{"- "}
{view.name}
</h6>
),
path: (view: IWorkspaceDefaultSearchResult) =>
`/${view?.workspace__slug}/projects/${view?.project_id}/views/${view?.id}`,
title: "Views",
},
module: {
icon: "dataset",
itemName: (module: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{module.project__identifier}</span>
{"- "}
{module.name}
</h6>
),
path: (module: IWorkspaceDefaultSearchResult) =>
`/${module?.workspace__slug}/projects/${module?.project_id}/modules/${module?.id}`,
title: "Modules",
},
page: {
icon: "article",
itemName: (page: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{page.project__identifier}</span>
{"- "}
{page.name}
</h6>
),
path: (page: IWorkspaceDefaultSearchResult) =>
`/${page?.workspace__slug}/projects/${page?.project_id}/pages/${page?.id}`,
title: "Pages",
},
project: {
icon: "work",
itemName: (project: IWorkspaceProjectSearchResult) => project?.name,
path: (project: IWorkspaceProjectSearchResult) =>
`/${project?.workspace__slug}/projects/${project?.id}/issues/`,
title: "Projects",
},
workspace: {
icon: "grid_view",
itemName: (workspace: IWorkspaceSearchResult) => workspace?.name,
path: (workspace: IWorkspaceSearchResult) => `/${workspace?.slug}/`,
title: "Workspaces",
},
};

View File

@ -1,6 +1,6 @@
export * from "./command-pallette"; export * from "./issue";
export * from "./shortcuts-modal";
export * from "./change-issue-state";
export * from "./change-issue-priority";
export * from "./change-issue-assignee";
export * from "./change-interface-theme"; export * from "./change-interface-theme";
export * from "./command-k";
export * from "./command-pallette";
export * from "./helpers";
export * from "./shortcuts-modal";

View File

@ -1,19 +1,23 @@
import { useRouter } from "next/router";
import React, { Dispatch, SetStateAction, useCallback } from "react"; import React, { Dispatch, SetStateAction, useCallback } from "react";
import useSWR, { mutate } from "swr";
import { useRouter } from "next/router";
import { mutate } from "swr";
// cmdk // cmdk
import { Command } from "cmdk"; import { Command } from "cmdk";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
// types // hooks
import { ICurrentUserResponse, IIssue } from "types"; import useProjectMembers from "hooks/use-project-members";
// constants // constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, PROJECT_MEMBERS } from "constants/fetch-keys"; import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// ui
import { Avatar } from "components/ui";
// icons // icons
import { CheckIcon } from "components/icons"; import { CheckIcon } from "components/icons";
import projectService from "services/project.service"; // types
import { Avatar } from "components/ui"; import { ICurrentUserResponse, IIssue } from "types";
type Props = { type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>; setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
@ -25,12 +29,7 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue,
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query; const { workspaceSlug, projectId, issueId } = router.query;
const { data: members } = useSWR( const { members } = useProjectMembers(workspaceSlug as string, projectId as string);
projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const options = const options =
members?.map(({ member }) => ({ members?.map(({ member }) => ({

View File

@ -34,7 +34,7 @@ export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue, use
? () => stateService.getStates(workspaceSlug as string, projectId as string) ? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null : null
); );
const states = getStatesList(stateGroups ?? {}); const states = getStatesList(stateGroups);
const submitChanges = useCallback( const submitChanges = useCallback(
async (formData: Partial<IIssue>) => { async (formData: Partial<IIssue>) => {

View File

@ -0,0 +1,3 @@
export * from "./change-issue-state";
export * from "./change-issue-priority";
export * from "./change-issue-assignee";

View File

@ -0,0 +1,497 @@
import { useRouter } from "next/router";
// icons
import { Icon, Tooltip } from "components/ui";
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon } from "components/icons";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper";
// types
import { IIssueActivity } from "types";
const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Tooltip
tooltipContent={
activity.issue_detail ? activity.issue_detail.name : "This issue has been deleted"
}
>
<a
href={`/${workspaceSlug}/projects/${activity.project}/issues/${activity.issue}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
{activity.issue_detail
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
: "Issue"}
<Icon iconName="launch" className="!text-xs" />
</a>
</Tooltip>
);
};
const activityDetails: {
[key: string]: {
message: (activity: IIssueActivity, showIssue: boolean) => React.ReactNode;
icon: React.ReactNode;
};
} = {
assignees: {
message: (activity, showIssue) => {
if (activity.old_value === "")
return (
<>
added a new assignee{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && (
<>
{" "}
to <IssueLink activity={activity} />
</>
)}
.
</>
);
else
return (
<>
removed the assignee{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
},
icon: <Icon iconName="group" className="!text-sm" aria-hidden="true" />,
},
archived_at: {
message: (activity) => {
if (activity.new_value === "restore") return "restored the issue.";
else return "archived the issue.";
},
icon: <Icon iconName="archive" className="!text-sm" aria-hidden="true" />,
},
attachment: {
message: (activity, showIssue) => {
if (activity.verb === "created")
return (
<>
uploaded a new{" "}
<a
href={`${activity.new_value}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
attachment
<Icon iconName="launch" className="!text-xs" />
</a>
{showIssue && (
<>
{" "}
to <IssueLink activity={activity} />
</>
)}
</>
);
else
return (
<>
removed an attachment
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
},
icon: <Icon iconName="attach_file" className="!text-sm" aria-hidden="true" />,
},
blocking: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked this issue is blocking issue{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the blocking issue{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
},
blocks: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked this issue is being blocked by{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed this issue being blocked by issue{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
},
cycles: {
message: (activity) => {
if (activity.verb === "created")
return (
<>
added this issue to the cycle{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else if (activity.verb === "updated")
return (
<>
set the cycle to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the issue from the cycle{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <Icon iconName="contrast" className="!text-sm" aria-hidden="true" />,
},
description: {
message: (activity, showIssue) => (
<>
updated the description
{showIssue && (
<>
{" "}
of <IssueLink activity={activity} />
</>
)}
.
</>
),
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
},
estimate_point: {
message: (activity, showIssue) => {
if (!activity.new_value)
return (
<>
removed the estimate point
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
else
return (
<>
set the estimate point to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && (
<>
{" "}
for <IssueLink activity={activity} />
</>
)}
.
</>
);
},
icon: <Icon iconName="change_history" className="!text-sm" aria-hidden="true" />,
},
issue: {
message: (activity) => {
if (activity.verb === "created") return "created the issue.";
else return "deleted an issue.";
},
icon: <Icon iconName="stack" className="!text-sm" aria-hidden="true" />,
},
labels: {
message: (activity, showIssue) => {
if (activity.old_value === "")
return (
<>
added a new label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
</span>
{showIssue && (
<>
{" "}
to <IssueLink activity={activity} />
</>
)}
</>
);
else
return (
<>
removed the label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
</span>
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
</>
);
},
icon: <Icon iconName="sell" className="!text-sm" aria-hidden="true" />,
},
link: {
message: (activity, showIssue) => {
if (activity.verb === "created")
return (
<>
added this{" "}
<a
href={`${activity.new_value}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
link
<Icon iconName="launch" className="!text-xs" />
</a>
{showIssue && (
<>
{" "}
to <IssueLink activity={activity} />
</>
)}
.
</>
);
else
return (
<>
removed this{" "}
<a
href={`${activity.old_value}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
link
<Icon iconName="launch" className="!text-xs" />
</a>
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
},
icon: <Icon iconName="link" className="!text-sm" aria-hidden="true" />,
},
modules: {
message: (activity) => {
if (activity.verb === "created")
return (
<>
added this issue to the module{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else if (activity.verb === "updated")
return (
<>
set the module to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the issue from the module{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <Icon iconName="dataset" className="!text-sm" aria-hidden="true" />,
},
name: {
message: (activity, showIssue) => (
<>
set the name to {activity.new_value}
{showIssue && (
<>
{" "}
of <IssueLink activity={activity} />
</>
)}
.
</>
),
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
},
parent: {
message: (activity, showIssue) => {
if (!activity.new_value)
return (
<>
removed the parent{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
else
return (
<>
set the parent to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && (
<>
{" "}
for <IssueLink activity={activity} />
</>
)}
.
</>
);
},
icon: <Icon iconName="supervised_user_circle" className="!text-sm" aria-hidden="true" />,
},
priority: {
message: (activity, showIssue) => (
<>
set the priority to{" "}
<span className="font-medium text-custom-text-100">
{activity.new_value ? capitalizeFirstLetter(activity.new_value) : "None"}
</span>
{showIssue && (
<>
{" "}
for <IssueLink activity={activity} />
</>
)}
.
</>
),
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />,
},
state: {
message: (activity, showIssue) => (
<>
set the state to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && (
<>
{" "}
for <IssueLink activity={activity} />
</>
)}
.
</>
),
icon: <Squares2X2Icon className="h-3 w-3" aria-hidden="true" />,
},
target_date: {
message: (activity, showIssue) => {
if (!activity.new_value)
return (
<>
removed the due date
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
else
return (
<>
set the due date to{" "}
<span className="font-medium text-custom-text-100">
{renderShortDateWithYearFormat(activity.new_value)}
</span>
{showIssue && (
<>
{" "}
for <IssueLink activity={activity} />
</>
)}
.
</>
);
},
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
},
};
export const ActivityIcon = ({ activity }: { activity: IIssueActivity }) => (
<>{activityDetails[activity.field as keyof typeof activityDetails]?.icon}</>
);
export const ActivityMessage = ({
activity,
showIssue = false,
}: {
activity: IIssueActivity;
showIssue?: boolean;
}) => (
<>
{activityDetails[activity.field as keyof typeof activityDetails]?.message(activity, showIssue)}
</>
);

View File

@ -1,5 +1,7 @@
import React from "react"; import React from "react";
import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
// icons // icons
@ -99,233 +101,242 @@ const activityDetails: {
}, },
}; };
export const Feeds: React.FC<any> = ({ activities }) => ( export const Feeds: React.FC<any> = ({ activities }) => {
<div> const router = useRouter();
<ul role="list" className="-mb-4"> const { workspaceSlug } = router.query;
{activities.map((activity: any, activityIdx: number) => {
// determines what type of action is performed return (
let action = activityDetails[activity.field as keyof typeof activityDetails]?.message; <div>
if (activity.field === "labels") { <ul role="list" className="-mb-4">
action = activity.new_value !== "" ? "added a new label" : "removed the label"; {activities.map((activity: any, activityIdx: number) => {
} else if (activity.field === "blocking") { // determines what type of action is performed
action = let action = activityDetails[activity.field as keyof typeof activityDetails]?.message;
activity.new_value !== "" if (activity.field === "labels") {
? "marked this issue is blocking" action = activity.new_value !== "" ? "added a new label" : "removed the label";
: "removed the issue from blocking"; } else if (activity.field === "blocking") {
} else if (activity.field === "blocks") { action =
action = activity.new_value !== ""
activity.new_value !== "" ? "marked this issue being blocked by" : "removed blocker"; ? "marked this issue is blocking"
} else if (activity.field === "target_date") { : "removed the issue from blocking";
action = } else if (activity.field === "blocks") {
activity.new_value && activity.new_value !== "" action =
? "set the due date to" activity.new_value !== "" ? "marked this issue being blocked by" : "removed blocker";
: "removed the due date"; } else if (activity.field === "target_date") {
} else if (activity.field === "parent") { action =
action = activity.new_value && activity.new_value !== ""
activity.new_value && activity.new_value !== "" ? "set the due date to"
? "set the parent to" : "removed the due date";
: "removed the parent"; } else if (activity.field === "parent") {
} else if (activity.field === "priority") { action =
action = activity.new_value && activity.new_value !== ""
activity.new_value && activity.new_value !== "" ? "set the parent to"
? "set the priority to" : "removed the parent";
: "removed the priority"; } else if (activity.field === "priority") {
} else if (activity.field === "description") { action =
action = "updated the"; activity.new_value && activity.new_value !== ""
} else if (activity.field === "attachment") { ? "set the priority to"
action = `${activity.verb} the`; : "removed the priority";
} else if (activity.field === "link") { } else if (activity.field === "description") {
action = `${activity.verb} the`; action = "updated the";
} else if (activity.field === "archived_at") { } else if (activity.field === "attachment") {
action = action = `${activity.verb} the`;
activity.new_value && activity.new_value === "restore" } else if (activity.field === "link") {
? "restored the issue" action = `${activity.verb} the`;
: "archived the issue"; } else if (activity.field === "archived_at") {
} action =
// for values that are after the action clause activity.new_value && activity.new_value === "restore"
let value: any = activity.new_value ? activity.new_value : activity.old_value; ? "restored the issue"
if ( : "archived the issue";
activity.verb === "created" && }
activity.field !== "cycles" && // for values that are after the action clause
activity.field !== "modules" && let value: any = activity.new_value ? activity.new_value : activity.old_value;
activity.field !== "attachment" && if (
activity.field !== "link" && activity.verb === "created" &&
activity.field !== "estimate" activity.field !== "cycles" &&
) { activity.field !== "modules" &&
const { workspace_detail, project, issue } = activity; activity.field !== "attachment" &&
value = ( activity.field !== "link" &&
<span className="text-custom-text-200"> activity.field !== "estimate"
created{" "} ) {
<Link href={`/${workspace_detail.slug}/projects/${project}/issues/${issue}`}> const { project, issue } = activity;
<a className="inline-flex items-center hover:underline"> value = (
this issue. <ArrowTopRightOnSquareIcon className="ml-1 h-3.5 w-3.5" /> <span className="text-custom-text-200">
</a> created{" "}
</Link> <Link href={`/${workspaceSlug}/projects/${project}/issues/${issue}`}>
</span> <a className="inline-flex items-center hover:underline">
); this issue. <ArrowTopRightOnSquareIcon className="ml-1 h-3.5 w-3.5" />
} else if (activity.field === "state") { </a>
value = activity.new_value ? addSpaceIfCamelCase(activity.new_value) : "None"; </Link>
} else if (activity.field === "labels") { </span>
let name; );
let id = "#000000"; } else if (activity.field === "state") {
if (activity.new_value !== "") { value = activity.new_value ? addSpaceIfCamelCase(activity.new_value) : "None";
name = activity.new_value; } else if (activity.field === "labels") {
id = activity.new_identifier ? activity.new_identifier : id; let name;
} else { let id = "#000000";
name = activity.old_value; if (activity.new_value !== "") {
id = activity.old_identifier ? activity.old_identifier : id; name = activity.new_value;
id = activity.new_identifier ? activity.new_identifier : id;
} else {
name = activity.old_value;
id = activity.old_identifier ? activity.old_identifier : id;
}
value = name;
} else if (activity.field === "assignees") {
value = activity.new_value;
} else if (activity.field === "target_date") {
const date =
activity.new_value && activity.new_value !== ""
? activity.new_value
: activity.old_value;
value = renderShortDateWithYearFormat(date as string);
} else if (activity.field === "description") {
value = "description";
} else if (activity.field === "attachment") {
value = "attachment";
} else if (activity.field === "link") {
value = "link";
} else if (activity.field === "estimate_point") {
value = activity.new_value
? activity.new_value +
` Point${parseInt(activity.new_value ?? "", 10) > 1 ? "s" : ""}`
: "None";
} }
value = name; if (activity.field === "comment") {
} else if (activity.field === "assignees") { return (
value = activity.new_value; <div key={activity.id} className="mt-2">
} else if (activity.field === "target_date") { <div className="relative flex items-start space-x-3">
const date = <div className="relative px-1">
activity.new_value && activity.new_value !== "" {activity.field ? (
? activity.new_value activity.new_value === "restore" ? (
: activity.old_value; <Icon iconName="history" className="text-sm text-custom-text-200" />
value = renderShortDateWithYearFormat(date as string); ) : (
} else if (activity.field === "description") { activityDetails[activity.field as keyof typeof activityDetails]?.icon
value = "description"; )
} else if (activity.field === "attachment") { ) : activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
value = "attachment"; <img
} else if (activity.field === "link") { src={activity.actor_detail.avatar}
value = "link"; alt={activity.actor_detail.first_name}
} else if (activity.field === "estimate_point") { height={30}
value = activity.new_value width={30}
? activity.new_value + ` Point${parseInt(activity.new_value ?? "", 10) > 1 ? "s" : ""}` className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
: "None"; />
}
if (activity.field === "comment") {
return (
<div key={activity.id} className="mt-2">
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{activity.field ? (
activity.new_value === "restore" ? (
<Icon iconName="history" className="text-sm text-custom-text-200" />
) : ( ) : (
activityDetails[activity.field as keyof typeof activityDetails]?.icon <div
) className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
) : activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? ( >
<img {activity.actor_detail.first_name.charAt(0)}
src={activity.actor_detail.avatar} </div>
alt={activity.actor_detail.first_name} )}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
>
{activity.actor_detail.first_name.charAt(0)}
</div>
)}
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-custom-background-80 px-0.5 py-px"> <span className="absolute -bottom-0.5 -right-1 rounded-tl bg-custom-background-80 px-0.5 py-px">
<ChatBubbleLeftEllipsisIcon <ChatBubbleLeftEllipsisIcon
className="h-3.5 w-3.5 text-custom-text-200" className="h-3.5 w-3.5 text-custom-text-200"
aria-hidden="true" aria-hidden="true"
/> />
</span> </span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-xs">
{activity.actor_detail.first_name}
{activity.actor_detail.is_bot ? "Bot" : " " + activity.actor_detail.last_name}
</div>
<p className="mt-0.5 text-xs text-custom-text-200">
Commented {timeAgo(activity.created_at)}
</p>
</div> </div>
<div className="issue-comments-section p-0"> <div className="min-w-0 flex-1">
<RemirrorRichTextEditor <div>
value={ <div className="text-xs">
activity.new_value && activity.new_value !== "" {activity.actor_detail.first_name}
? activity.new_value {activity.actor_detail.is_bot
: activity.old_value ? "Bot"
} : " " + activity.actor_detail.last_name}
editable={false} </div>
noBorder <p className="mt-0.5 text-xs text-custom-text-200">
customClassName="text-xs border border-custom-border-200 bg-custom-background-100" Commented {timeAgo(activity.created_at)}
/> </p>
</div>
<div className="issue-comments-section p-0">
<RemirrorRichTextEditor
value={
activity.new_value && activity.new_value !== ""
? activity.new_value
: activity.old_value
}
editable={false}
noBorder
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> );
); }
}
if ("field" in activity && activity.field !== "updated_by") { if ("field" in activity && activity.field !== "updated_by") {
return ( return (
<li key={activity.id}> <li key={activity.id}>
<div className="relative pb-1"> <div className="relative pb-1">
{activities.length > 1 && activityIdx !== activities.length - 1 ? ( {activities.length > 1 && activityIdx !== activities.length - 1 ? (
<span <span
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80" className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80"
aria-hidden="true" aria-hidden="true"
/> />
) : null} ) : null}
<div className="relative flex items-start space-x-2"> <div className="relative flex items-start space-x-2">
<> <>
<div> <div>
<div className="relative px-1.5"> <div className="relative px-1.5">
<div className="mt-1.5"> <div className="mt-1.5">
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white"> <div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
{activity.field ? ( {activity.field ? (
activityDetails[activity.field as keyof typeof activityDetails]?.icon activityDetails[activity.field as keyof typeof activityDetails]
) : activity.actor_detail.avatar && ?.icon
activity.actor_detail.avatar !== "" ? ( ) : activity.actor_detail.avatar &&
<img activity.actor_detail.avatar !== "" ? (
src={activity.actor_detail.avatar} <img
alt={activity.actor_detail.first_name} src={activity.actor_detail.avatar}
height={24} alt={activity.actor_detail.first_name}
width={24} height={24}
className="rounded-full" width={24}
/> className="rounded-full"
) : ( />
<div ) : (
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`} <div
> className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
{activity.actor_detail.first_name.charAt(0)} >
</div> {activity.actor_detail.first_name.charAt(0)}
)} </div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <div className="min-w-0 flex-1 py-3">
<div className="min-w-0 flex-1 py-3"> <div className="text-xs text-custom-text-200">
<div className="text-xs text-custom-text-200"> {activity.field === "archived_at" && activity.new_value !== "restore" ? (
{activity.field === "archived_at" && activity.new_value !== "restore" ? ( <span className="text-gray font-medium">Plane</span>
<span className="text-gray font-medium">Plane</span> ) : (
) : ( <span className="text-gray font-medium">
<span className="text-gray font-medium"> {activity.actor_detail.first_name}
{activity.actor_detail.first_name} {activity.actor_detail.is_bot
{activity.actor_detail.is_bot ? " Bot"
? " Bot" : " " + activity.actor_detail.last_name}
: " " + activity.actor_detail.last_name} </span>
</span> )}
)} <span> {action} </span>
<span> {action} </span> {activity.field !== "archived_at" && (
{activity.field !== "archived_at" && ( <span className="text-xs font-medium text-custom-text-100">
<span className="text-xs font-medium text-custom-text-100"> {" "}
{" "} {value}{" "}
{value}{" "} </span>
</span> )}
)} <span className="whitespace-nowrap">{timeAgo(activity.created_at)}</span>
<span className="whitespace-nowrap">{timeAgo(activity.created_at)}</span> </div>
</div> </div>
</div> </>
</> </div>
</div> </div>
</div> </li>
</li> );
); }
} })}
})} </ul>
</ul> </div>
</div> );
); };

View File

@ -1,6 +1,4 @@
import React from "react"; import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// icons // icons
import { XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
@ -8,43 +6,31 @@ import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// ui // ui
import { Avatar } from "components/ui"; import { Avatar } from "components/ui";
// helpers // helpers
import { getStatesList } from "helpers/state.helper";
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// services // helpers
import issuesService from "services/issues.service";
import projectService from "services/project.service";
import stateService from "services/state.service";
// types
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fetch-keys";
import { IIssueFilterOptions } from "types";
import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import { IIssueFilterOptions, IIssueLabels, IState, IUserLite, TStateGroups } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
export const FilterList: React.FC<any> = ({ filters, setFilters }) => { type Props = {
const router = useRouter(); filters: Partial<IIssueFilterOptions>;
const { workspaceSlug, projectId, viewId } = router.query; setFilters: (updatedFilter: Partial<IIssueFilterOptions>) => void;
clearAllFilters: (...args: any) => void;
const { data: members } = useSWR( labels: IIssueLabels[] | undefined;
projectId ? PROJECT_MEMBERS(projectId as string) : null, members: IUserLite[] | undefined;
workspaceSlug && projectId states: IState[] | undefined;
? () => projectService.projectMembers(workspaceSlug as string, projectId as string) };
: null
);
const { data: issueLabels } = useSWR(
projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId.toString())
: null
);
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups ?? {});
export const FiltersList: React.FC<Props> = ({
filters,
setFilters,
clearAllFilters,
labels,
members,
states,
}) => {
if (!filters) return <></>; if (!filters) return <></>;
const nullFilters = Object.keys(filters).filter( const nullFilters = Object.keys(filters).filter(
@ -53,24 +39,26 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
return ( return (
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs"> <div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
{Object.keys(filters).map((key) => { {Object.keys(filters).map((filterKey) => {
if (filters[key as keyof typeof filters] !== null) const key = filterKey as keyof typeof filters;
return (
<div if (filters[key] === null) return null;
key={key}
className="flex items-center gap-x-2 rounded-full border border-custom-border-200 bg-custom-background-80 px-2 py-1" return (
> <div
<span className="capitalize text-custom-text-200"> key={key}
{key === "target_date" ? "Due Date" : replaceUnderscoreIfSnakeCase(key)}: className="flex items-center gap-x-2 rounded-full border border-custom-border-200 bg-custom-background-80 px-2 py-1"
</span> >
{filters[key as keyof IIssueFilterOptions] === null || <span className="capitalize text-custom-text-200">
(filters[key as keyof IIssueFilterOptions]?.length ?? 0) <= 0 ? ( {key === "target_date" ? "Due Date" : replaceUnderscoreIfSnakeCase(key)}:
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span> </span>
) : Array.isArray(filters[key as keyof IIssueFilterOptions]) ? ( {filters[key] === null || (filters[key]?.length ?? 0) <= 0 ? (
<div className="space-x-2"> <span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
{key === "state" ? ( ) : Array.isArray(filters[key]) ? (
<div className="flex flex-wrap items-center gap-1"> <div className="space-x-2">
{filters.state?.map((stateId: any) => { <div className="flex flex-wrap items-center gap-1">
{key === "state"
? filters.state?.map((stateId: string) => {
const state = states?.find((s) => s.id === stateId); const state = states?.find((s) => s.id === stateId);
return ( return (
@ -94,33 +82,46 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
<span <span
className="cursor-pointer" className="cursor-pointer"
onClick={() => onClick={() =>
setFilters( setFilters({
{ state: filters.state?.filter((s: any) => s !== stateId),
state: filters.state?.filter((s: any) => s !== stateId), })
},
!Boolean(viewId)
)
} }
> >
<XMarkIcon className="h-3 w-3" /> <XMarkIcon className="h-3 w-3" />
</span> </span>
</p> </p>
); );
})} })
<button : key === "state_group"
type="button" ? filters.state_group?.map((stateGroup) => {
onClick={() => const group = stateGroup as TStateGroups;
setFilters({
state: null, return (
}) <p
} key={group}
> className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
<XMarkIcon className="h-3 w-3" /> style={{
</button> color: STATE_GROUP_COLORS[group],
</div> backgroundColor: `${STATE_GROUP_COLORS[group]}20`,
) : key === "priority" ? ( }}
<div className="flex flex-wrap items-center gap-1"> >
{filters.priority?.map((priority: any) => ( <span>{getStateGroupIcon(group, "16", "16")}</span>
<span>{group}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
state_group: filters.state_group?.filter((g) => g !== group),
})
}
>
<XMarkIcon className="h-3 w-3" />
</span>
</p>
);
})
: key === "priority"
? filters.priority?.map((priority: any) => (
<p <p
key={priority} key={priority}
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize ${ className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize ${
@ -140,33 +141,18 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
<span <span
className="cursor-pointer" className="cursor-pointer"
onClick={() => onClick={() =>
setFilters( setFilters({
{ priority: filters.priority?.filter((p: any) => p !== priority),
priority: filters.priority?.filter((p: any) => p !== priority), })
},
!Boolean(viewId)
)
} }
> >
<XMarkIcon className="h-3 w-3" /> <XMarkIcon className="h-3 w-3" />
</span> </span>
</p> </p>
))} ))
<button : key === "assignees"
type="button" ? filters.assignees?.map((memberId: string) => {
onClick={() => const member = members?.find((m) => m.id === memberId);
setFilters({
priority: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : key === "assignees" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.assignees?.map((memberId: string) => {
const member = members?.find((m) => m.member.id === memberId)?.member;
return ( return (
<div <div
@ -178,36 +164,19 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
<span <span
className="cursor-pointer" className="cursor-pointer"
onClick={() => onClick={() =>
setFilters( setFilters({
{ assignees: filters.assignees?.filter((p: any) => p !== memberId),
assignees: filters.assignees?.filter( })
(p: any) => p !== memberId
),
},
!Boolean(viewId)
)
} }
> >
<XMarkIcon className="h-3 w-3" /> <XMarkIcon className="h-3 w-3" />
</span> </span>
</div> </div>
); );
})} })
<button : key === "created_by"
type="button" ? filters.created_by?.map((memberId: string) => {
onClick={() => const member = members?.find((m) => m.id === memberId);
setFilters({
assignees: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : key === "created_by" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.created_by?.map((memberId: string) => {
const member = members?.find((m) => m.member.id === memberId)?.member;
return ( return (
<div <div
@ -219,36 +188,21 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
<span <span
className="cursor-pointer" className="cursor-pointer"
onClick={() => onClick={() =>
setFilters( setFilters({
{ created_by: filters.created_by?.filter(
created_by: filters.created_by?.filter( (p: any) => p !== memberId
(p: any) => p !== memberId ),
), })
},
!Boolean(viewId)
)
} }
> >
<XMarkIcon className="h-3 w-3" /> <XMarkIcon className="h-3 w-3" />
</span> </span>
</div> </div>
); );
})} })
<button : key === "labels"
type="button" ? filters.labels?.map((labelId: string) => {
onClick={() => const label = labels?.find((l) => l.id === labelId);
setFilters({
created_by: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : key === "labels" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.labels?.map((labelId: string) => {
const label = issueLabels?.find((l) => l.id === labelId);
if (!label) return null; if (!label) return null;
const color = label.color !== "" ? label.color : "#0f172a"; const color = label.color !== "" ? label.color : "#0f172a";
@ -271,12 +225,9 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
<span <span
className="cursor-pointer" className="cursor-pointer"
onClick={() => onClick={() =>
setFilters( setFilters({
{ labels: filters.labels?.filter((l: any) => l !== labelId),
labels: filters.labels?.filter((l: any) => l !== labelId), })
},
!Boolean(viewId)
)
} }
> >
<XMarkIcon <XMarkIcon
@ -288,22 +239,10 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
</span> </span>
</div> </div>
); );
})} })
<button : key === "target_date"
type="button" ? filters.target_date?.map((date: string) => {
onClick={() => if (filters.target_date && filters.target_date.length <= 0) return null;
setFilters({
labels: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : key === "target_date" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.target_date?.map((date: string) => {
if (filters.target_date.length <= 0) return null;
const splitDate = date.split(";"); const splitDate = date.split(";");
@ -319,39 +258,17 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
<span <span
className="cursor-pointer" className="cursor-pointer"
onClick={() => onClick={() =>
setFilters( setFilters({
{ target_date: filters.target_date?.filter((d: any) => d !== date),
target_date: filters.target_date?.filter( })
(d: any) => d !== date
),
},
!Boolean(viewId)
)
} }
> >
<XMarkIcon className="h-3 w-3" /> <XMarkIcon className="h-3 w-3" />
</span> </span>
</div> </div>
); );
})} })
<button : (filters[key] as any)?.join(", ")}
type="button"
onClick={() =>
setFilters({
target_date: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
) : (
(filters[key as keyof IIssueFilterOptions] as any)?.join(", ")
)}
</div>
) : (
<div className="flex items-center gap-x-1 capitalize">
{filters[key as keyof typeof filters]}
<button <button
type="button" type="button"
onClick={() => onClick={() =>
@ -363,24 +280,29 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
<XMarkIcon className="h-3 w-3" /> <XMarkIcon className="h-3 w-3" />
</button> </button>
</div> </div>
)} </div>
</div> ) : (
); <div className="flex items-center gap-x-1 capitalize">
{filters[key as keyof typeof filters]}
<button
type="button"
onClick={() =>
setFilters({
[key]: null,
})
}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
)}
</div>
);
})} })}
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && ( {Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
<button <button
type="button" type="button"
onClick={() => onClick={clearAllFilters}
setFilters({
type: null,
state: null,
priority: null,
assignees: null,
labels: null,
created_by: null,
target_date: null,
})
}
className="flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-80 px-3 py-1.5 text-xs" className="flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-80 px-3 py-1.5 text-xs"
> >
<span>Clear all filters</span> <span>Clear all filters</span>

View File

@ -65,6 +65,8 @@ export const IssuesFilterView: React.FC = () => {
orderBy, orderBy,
setOrderBy, setOrderBy,
showEmptyGroups, showEmptyGroups,
showSubIssues,
setShowSubIssues,
setShowEmptyGroups, setShowEmptyGroups,
filters, filters,
setFilters, setFilters,
@ -179,79 +181,106 @@ export const IssuesFilterView: React.FC = () => {
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Group by</h4> <h4 className="text-custom-text-200">Group by</h4>
<CustomMenu <div className="w-28">
label={ <CustomMenu
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty) label={
?.name ?? "Select" GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
} ?.name ?? "Select"
> }
{GROUP_BY_OPTIONS.map((option) => className="!w-full"
issueView === "kanban" && option.key === null ? null : ( buttonClassName="w-full"
<CustomMenu.MenuItem >
key={option.key} {GROUP_BY_OPTIONS.map((option) => {
onClick={() => setGroupByProperty(option.key)} if (issueView === "kanban" && option.key === null) return null;
> if (option.key === "project") return null;
{option.name}
</CustomMenu.MenuItem> return (
) <CustomMenu.MenuItem
)} key={option.key}
</CustomMenu> onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4> <h4 className="text-custom-text-200">Order by</h4>
<CustomMenu <div className="w-28">
label={ <CustomMenu
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ?? label={
"Select" ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
} "Select"
> }
{ORDER_BY_OPTIONS.map((option) => className="!w-full"
groupByProperty === "priority" && option.key === "priority" ? null : ( buttonClassName="w-full"
<CustomMenu.MenuItem >
key={option.key} {ORDER_BY_OPTIONS.map((option) =>
onClick={() => { groupByProperty === "priority" &&
setOrderBy(option.key); option.key === "priority" ? null : (
}} <CustomMenu.MenuItem
> key={option.key}
{option.name} onClick={() => {
</CustomMenu.MenuItem> setOrderBy(option.key);
) }}
)} >
</CustomMenu> {option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
</div> </div>
</> </>
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4> <h4 className="text-custom-text-200">Issue type</h4>
<CustomMenu <div className="w-28">
label={ <CustomMenu
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters.type) label={
?.name ?? "Select" FILTER_ISSUE_OPTIONS.find((option) => option.key === filters.type)
} ?.name ?? "Select"
> }
{FILTER_ISSUE_OPTIONS.map((option) => ( className="!w-full"
<CustomMenu.MenuItem buttonClassName="w-full"
key={option.key} >
onClick={() => {FILTER_ISSUE_OPTIONS.map((option) => (
setFilters({ <CustomMenu.MenuItem
type: option.key, key={option.key}
}) onClick={() =>
} setFilters({
> type: option.key,
{option.name} })
</CustomMenu.MenuItem> }
))} >
</CustomMenu> {option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
</div> </div>
{issueView !== "calendar" && issueView !== "spreadsheet" && ( {issueView !== "calendar" && issueView !== "spreadsheet" && (
<> <>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show sub-issues</h4>
<div className="w-28">
<ToggleSwitch
value={showSubIssues}
onChange={() => setShowSubIssues(!showSubIssues)}
/>
</div>
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</h4> <h4 className="text-custom-text-200">Show empty states</h4>
<ToggleSwitch <div className="w-28">
value={showEmptyGroups} <ToggleSwitch
onChange={() => setShowEmptyGroups(!showEmptyGroups)} value={showEmptyGroups}
/> onChange={() => setShowEmptyGroups(!showEmptyGroups)}
/>
</div>
</div> </div>
<div className="relative flex justify-end gap-x-3"> <div className="relative flex justify-end gap-x-3">
<button type="button" onClick={() => resetFilterToDefault()}> <button type="button" onClick={() => resetFilterToDefault()}>
@ -271,7 +300,7 @@ export const IssuesFilterView: React.FC = () => {
<div className="space-y-2 py-3"> <div className="space-y-2 py-3">
<h4 className="text-sm text-custom-text-200">Display Properties</h4> <h4 className="text-sm text-custom-text-200">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2 text-custom-text-200">
{Object.keys(properties).map((key) => { {Object.keys(properties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null; if (key === "estimate" && !isEstimateActive) return null;

View File

@ -1,8 +1,15 @@
import React, { useEffect, useState, useRef } from "react"; import React, { useEffect, useState, useRef, useCallback } from "react";
// next
import Image from "next/image";
import { useRouter } from "next/router";
// swr // swr
import useSWR from "swr"; import useSWR from "swr";
// react-dropdown
import { useDropzone } from "react-dropzone";
// headless ui // headless ui
import { Tab, Transition, Popover } from "@headlessui/react"; import { Tab, Transition, Popover } from "@headlessui/react";
@ -10,9 +17,9 @@ import { Tab, Transition, Popover } from "@headlessui/react";
import fileService from "services/file.service"; import fileService from "services/file.service";
// components // components
import { Input, Spinner, PrimaryButton } from "components/ui"; import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui";
// hooks // hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector"; import useWorkspaceDetails from "hooks/use-workspace-details";
const unsplashEnabled = const unsplashEnabled =
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" || process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
@ -38,6 +45,12 @@ type Props = {
export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange }) => { export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange }) => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const router = useRouter();
const { workspaceSlug } = router.query;
const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchParams, setSearchParams] = useState(""); const [searchParams, setSearchParams] = useState("");
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@ -48,10 +61,50 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
fileService.getUnsplashImages(1, searchParams) fileService.getUnsplashImages(1, searchParams)
); );
useOutsideClickDetector(ref, () => { const { workspaceDetails } = useWorkspaceDetails();
setIsOpen(false);
const onDrop = useCallback((acceptedFiles: File[]) => {
setImage(acceptedFiles[0]);
}, []);
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: 5 * 1024 * 1024,
}); });
const handleSubmit = async () => {
setIsImageUploading(true);
if (!image || !workspaceSlug) return;
const formData = new FormData();
formData.append("asset", image);
formData.append("attributes", JSON.stringify({}));
fileService
.uploadFile(workspaceSlug.toString(), formData)
.then((res) => {
const oldValue = value;
const isUnsplashImage = oldValue?.split("/")[2] === "images.unsplash.com";
const imageUrl = res.asset;
onChange(imageUrl);
setIsImageUploading(false);
setImage(null);
setIsOpen(false);
if (isUnsplashImage) return;
if (oldValue && workspaceDetails) fileService.deleteFile(workspaceDetails.id, oldValue);
})
.catch((err) => {
console.log(err);
});
};
useEffect(() => { useEffect(() => {
if (!images || value !== null) return; if (!images || value !== null) return;
onChange(images[0].urls.regular); onChange(images[0].urls.regular);
@ -62,7 +115,7 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
return ( return (
<Popover className="relative z-[2]" ref={ref}> <Popover className="relative z-[2]" ref={ref}>
<Popover.Button <Popover.Button
className="rounded-md border border-custom-border-200 bg-custom-background-80 px-2 py-1 text-xs text-custom-text-200" className="rounded-md border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
onClick={() => setIsOpen((prev) => !prev)} onClick={() => setIsOpen((prev) => !prev)}
> >
{label} {label}
@ -77,22 +130,24 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-80 shadow-lg"> <Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-80 shadow-lg">
<div className="h-96 w-80 overflow-auto rounded border border-custom-border-200 bg-custom-background-80 p-5 shadow-2xl sm:max-w-2xl md:w-96 lg:w-[40rem]"> <div className="h-96 md:h-[28rem] w-80 md:w-[36rem] flex flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl">
<Tab.Group> <Tab.Group>
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1"> <div>
{tabOptions.map((tab) => ( <Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
<Tab {tabOptions.map((tab) => (
key={tab.key} <Tab
className={({ selected }) => key={tab.key}
`rounded py-1 px-4 text-center text-sm outline-none transition-colors ${ className={({ selected }) =>
selected ? "bg-custom-primary text-white" : "text-custom-text-100" `rounded py-1 px-4 text-center text-sm outline-none transition-colors ${
}` selected ? "bg-custom-primary text-white" : "text-custom-text-100"
} }`
> }
{tab.title} >
</Tab> {tab.title}
))} </Tab>
</Tab.List> ))}
</Tab.List>
</div>
<Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden"> <Tab.Panels className="h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
<Tab.Panel className="h-full w-full space-y-4"> <Tab.Panel className="h-full w-full space-y-4">
<div className="flex gap-x-2 pt-7"> <div className="flex gap-x-2 pt-7">
@ -133,8 +188,78 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
</div> </div>
)} )}
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="flex h-full w-full flex-col items-center justify-center"> <Tab.Panel className="h-full w-full pt-5">
<p>Coming Soon...</p> <div className="w-full h-full flex flex-col gap-y-2">
<div className="flex items-center gap-3 w-full flex-1">
<div
{...getRootProps()}
className={`relative grid h-full w-full cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${
(image === null && isDragActive) || !value
? "border-2 border-dashed border-custom-border-200 hover:bg-custom-background-90"
: ""
}`}
>
<button
type="button"
className="absolute top-0 right-0 z-40 -translate-y-1/2 rounded bg-custom-background-90 px-2 py-0.5 text-xs font-medium text-custom-text-200"
>
Edit
</button>
{image !== null || (value && value !== "") ? (
<>
<Image
layout="fill"
objectFit="cover"
src={image ? URL.createObjectURL(image) : value ? value : ""}
alt="image"
className="rounded-lg"
/>
</>
) : (
<div>
<span className="mt-2 block text-sm font-medium text-custom-text-200">
{isDragActive
? "Drop image here to upload"
: "Drag & drop image here"}
</span>
</div>
)}
<input {...getInputProps()} type="text" />
</div>
</div>
{fileRejections.length > 0 && (
<p className="text-sm text-red-500">
{fileRejections[0].errors[0].code === "file-too-large"
? "The image size cannot exceed 5 MB."
: "Please upload a file in a valid format."}
</p>
)}
<p className="text-custom-text-200 text-sm">
File formats supported- .jpeg, .jpg, .png, .webp, .svg
</p>
<div className="flex items-center justify-end gap-2">
<SecondaryButton
className="w-full"
onClick={() => {
setIsOpen(false);
setImage(null);
}}
>
Cancel
</SecondaryButton>
<PrimaryButton
className="w-full"
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</PrimaryButton>
</div>
</div>
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@ -1,12 +1,9 @@
export * from "./board-view";
export * from "./calendar-view";
export * from "./filters"; export * from "./filters";
export * from "./gantt-chart-view";
export * from "./list-view";
export * from "./modals"; export * from "./modals";
export * from "./spreadsheet-view";
export * from "./theme";
export * from "./sidebar"; export * from "./sidebar";
export * from "./issues-view"; export * from "./theme";
export * from "./image-picker-popover"; export * from "./views";
export * from "./activity";
export * from "./feeds"; export * from "./feeds";
export * from "./reaction-selector";
export * from "./image-picker-popover";

View File

@ -1,72 +0,0 @@
// hooks
import useIssuesView from "hooks/use-issues-view";
// components
import { SingleList } from "components/core/list-view/single-list";
// types
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types";
// types
type Props = {
type: "issue" | "cycle" | "module";
states: IState[] | undefined;
addIssueToState: (groupTitle: string) => void;
makeIssueCopy: (issue: IIssue) => void;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
isCompleted?: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
};
export const AllLists: React.FC<Props> = ({
type,
states,
addIssueToState,
makeIssueCopy,
openIssuesListModal,
handleEditIssue,
handleDeleteIssue,
removeIssue,
isCompleted = false,
user,
userAuth,
}) => {
const { groupedByIssues, groupByProperty: selectedGroup, showEmptyGroups } = useIssuesView();
return (
<>
{groupedByIssues && (
<div className="h-full overflow-y-auto">
{Object.keys(groupedByIssues).map((singleGroup) => {
const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null;
return (
<SingleList
key={singleGroup}
type={type}
groupTitle={singleGroup}
groupedByIssues={groupedByIssues}
selectedGroup={selectedGroup}
currentState={currentState}
addIssueToState={() => addIssueToState(singleGroup)}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={removeIssue}
isCompleted={isCompleted}
user={user}
userAuth={userAuth}
/>
);
})}
</div>
)}
</>
);
};

View File

@ -13,8 +13,9 @@ import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view"; import useIssuesView from "hooks/use-issues-view";
import useDebounce from "hooks/use-debounce"; import useDebounce from "hooks/use-debounce";
// ui // ui
import { Loader, PrimaryButton, SecondaryButton } from "components/ui"; import { Loader, PrimaryButton, SecondaryButton, ToggleSwitch, Tooltip } from "components/ui";
// icons // icons
import { LaunchOutlined } from "@mui/icons-material";
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons"; import { LayerDiagonalIcon } from "components/icons";
// types // types
@ -32,6 +33,7 @@ type Props = {
handleClose: () => void; handleClose: () => void;
searchParams: Partial<TProjectIssuesSearchParams>; searchParams: Partial<TProjectIssuesSearchParams>;
handleOnSubmit: (data: ISearchIssueResponse[]) => Promise<void>; handleOnSubmit: (data: ISearchIssueResponse[]) => Promise<void>;
workspaceLevelToggle?: boolean;
}; };
export const ExistingIssuesListModal: React.FC<Props> = ({ export const ExistingIssuesListModal: React.FC<Props> = ({
@ -39,13 +41,14 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
handleClose: onClose, handleClose: onClose,
searchParams, searchParams,
handleOnSubmit, handleOnSubmit,
workspaceLevelToggle = false,
}) => { }) => {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]); const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [selectedIssues, setSelectedIssues] = useState<ISearchIssueResponse[]>([]); const [selectedIssues, setSelectedIssues] = useState<ISearchIssueResponse[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const debouncedSearchTerm: string = useDebounce(searchTerm, 500); const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
@ -60,6 +63,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
onClose(); onClose();
setSearchTerm(""); setSearchTerm("");
setSelectedIssues([]); setSelectedIssues([]);
setIsWorkspaceLevel(false);
}; };
const onSubmit = async () => { const onSubmit = async () => {
@ -97,31 +101,19 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
}; };
useEffect(() => { useEffect(() => {
if (!workspaceSlug || !projectId) return; if (!isOpen || !workspaceSlug || !projectId) return;
setIsLoading(true); setIsSearching(true);
if (debouncedSearchTerm) { projectService
setIsSearching(true); .projectIssuesSearch(workspaceSlug as string, projectId as string, {
search: debouncedSearchTerm,
projectService ...searchParams,
.projectIssuesSearch(workspaceSlug as string, projectId as string, { workspace_search: isWorkspaceLevel,
search: debouncedSearchTerm, })
...searchParams, .then((res) => setIssues(res))
}) .finally(() => setIsSearching(false));
.then((res) => { }, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
setIssues(res);
})
.finally(() => {
setIsLoading(false);
setIsSearching(false);
});
} else {
setIssues([]);
setIsLoading(false);
setIsSearching(false);
}
}, [debouncedSearchTerm, workspaceSlug, projectId, searchParams]);
return ( return (
<> <>
@ -169,14 +161,14 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
aria-hidden="true" aria-hidden="true"
/> />
<Combobox.Input <Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-custom-text-100 outline-none focus:ring-0 sm:text-sm" className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-custom-text-100 outline-none focus:ring-0 text-sm placeholder:text-custom-text-400"
placeholder="Type to search..." placeholder="Type to search..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div> </div>
<div className="text-custom-text-200 text-[0.825rem] p-2"> <div className="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-between gap-4 text-custom-text-200 text-[0.825rem] p-2">
{selectedIssues.length > 0 ? ( {selectedIssues.length > 0 ? (
<div className="flex items-center gap-2 flex-wrap mt-1"> <div className="flex items-center gap-2 flex-wrap mt-1">
{selectedIssues.map((issue) => ( {selectedIssues.map((issue) => (
@ -204,22 +196,43 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
No issues selected No issues selected
</div> </div>
)} )}
{workspaceLevelToggle && (
<Tooltip tooltipContent="Toggle workspace level search">
<div
className={`flex-shrink-0 flex items-center gap-1 text-xs cursor-pointer ${
isWorkspaceLevel ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<ToggleSwitch
value={isWorkspaceLevel}
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
/>
<button
type="button"
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
className="flex-shrink-0"
>
workspace level
</button>
</div>
</Tooltip>
)}
</div> </div>
<Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto mt-2"> <Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto">
{debouncedSearchTerm !== "" && ( {searchTerm !== "" && (
<h5 className="text-[0.825rem] text-custom-text-200 mx-2"> <h5 className="text-[0.825rem] text-custom-text-200 mx-2">
Search results for{" "} Search results for{" "}
<span className="text-custom-text-100"> <span className="text-custom-text-100">
{'"'} {'"'}
{debouncedSearchTerm} {searchTerm}
{'"'} {'"'}
</span>{" "} </span>{" "}
in project: in project:
</h5> </h5>
)} )}
{!isLoading && {!isSearching &&
issues.length === 0 && issues.length === 0 &&
searchTerm !== "" && searchTerm !== "" &&
debouncedSearchTerm !== "" && ( debouncedSearchTerm !== "" && (
@ -235,7 +248,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
</div> </div>
)} )}
{isLoading || isSearching ? ( {isSearching ? (
<Loader className="space-y-3 p-3"> <Loader className="space-y-3 p-3">
<Loader.Item height="40px" /> <Loader.Item height="40px" />
<Loader.Item height="40px" /> <Loader.Item height="40px" />
@ -256,22 +269,37 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
htmlFor={`issue-${issue.id}`} htmlFor={`issue-${issue.id}`}
value={issue} value={issue}
className={({ active }) => className={({ active }) =>
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${ `group flex items-center justify-between gap-2 w-full cursor-pointer select-none rounded-md px-3 py-2 text-custom-text-200 ${
active ? "bg-custom-background-80 text-custom-text-100" : "" active ? "bg-custom-background-80 text-custom-text-100" : ""
} ${selected ? "text-custom-text-100" : ""}` } ${selected ? "text-custom-text-100" : ""}`
} }
> >
<input type="checkbox" checked={selected} readOnly /> <div className="flex items-center gap-2">
<span <input type="checkbox" checked={selected} readOnly />
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full" <span
style={{ className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
backgroundColor: issue.state__color, style={{
}} backgroundColor: issue.state__color,
/> }}
<span className="flex-shrink-0 text-xs"> />
{issue.project__identifier}-{issue.sequence_id} <span className="flex-shrink-0 text-xs">
</span> {issue.project__identifier}-{issue.sequence_id}
{issue.name} </span>
{issue.name}
</div>
<a
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
target="_blank"
className="group-hover:block hidden relative z-1 text-custom-text-200 hover:text-custom-text-100"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<LaunchOutlined
sx={{
fontSize: 16,
}}
/>
</a>
</Combobox.Option> </Combobox.Option>
); );
})} })}

View File

@ -117,18 +117,21 @@ export const GptAssistantModal: React.FC<Props> = ({
else setInvalidResponse(false); else setInvalidResponse(false);
}) })
.catch((err) => { .catch((err) => {
const error = err?.data?.error;
if (err.status === 429) if (err.status === 429)
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: message:
error ||
"You have reached the maximum number of requests of 50 requests per month per user.", "You have reached the maximum number of requests of 50 requests per month per user.",
}); });
else else
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: "Some error occurred. Please try again.", message: error || "Some error occurred. Please try again.",
}); });
}); });
}; };

View File

@ -43,8 +43,12 @@ export const ImageUploadModal: React.FC<Props> = ({
setImage(acceptedFiles[0]); setImage(acceptedFiles[0]);
}, []); }, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop, onDrop,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: 5 * 1024 * 1024,
}); });
const handleSubmit = async () => { const handleSubmit = async () => {
@ -166,9 +170,19 @@ export const ImageUploadModal: React.FC<Props> = ({
<input {...getInputProps()} type="text" /> <input {...getInputProps()} type="text" />
</div> </div>
</div> </div>
{fileRejections.length > 0 && (
<p className="text-sm text-red-500">
{fileRejections[0].errors[0].code === "file-too-large"
? "The image size cannot exceed 5 MB."
: "Please upload a file in a valid format."}
</p>
)}
</div> </div>
</div> </div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3"> <p className="my-4 text-custom-text-200 text-sm">
File formats supported- .jpeg, .jpg, .png, .webp, .svg
</p>
<div className="flex items-center justify-end gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton> <SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton <PrimaryButton
onClick={handleSubmit} onClick={handleSubmit}

View File

@ -15,7 +15,7 @@ type Props = {
onFormSubmit: (formData: IIssueLink | ModuleLink) => Promise<void>; onFormSubmit: (formData: IIssueLink | ModuleLink) => Promise<void>;
}; };
const defaultValues: ModuleLink = { const defaultValues: IIssueLink | ModuleLink = {
title: "", title: "",
url: "", url: "",
}; };
@ -30,9 +30,8 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
defaultValues, defaultValues,
}); });
const onSubmit = async (formData: ModuleLink) => { const onSubmit = async (formData: IIssueLink | ModuleLink) => {
await onFormSubmit(formData); await onFormSubmit({ title: formData.title, url: formData.url });
onClose(); onClose();
}; };
@ -87,7 +86,7 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
label="URL" label="URL"
name="url" name="url"
type="url" type="url"
placeholder="Enter URL" placeholder="https://..."
autoComplete="off" autoComplete="off"
error={errors.url} error={errors.url}
register={register} register={register}
@ -99,16 +98,13 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
<div> <div>
<Input <Input
id="title" id="title"
label="Title" label="Title (optional)"
name="title" name="title"
type="text" type="text"
placeholder="Enter title" placeholder="Enter title"
autoComplete="off" autoComplete="off"
error={errors.title} error={errors.title}
register={register} register={register}
validations={{
required: "Title is required",
}}
/> />
</div> </div>
</div> </div>
@ -116,7 +112,7 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
</div> </div>
<div className="mt-5 flex justify-end gap-2"> <div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton> <SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}> <PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Adding Link..." : "Add Link"} {isSubmitting ? "Adding Link..." : "Add Link"}
</PrimaryButton> </PrimaryButton>
</div> </div>

View File

@ -0,0 +1,87 @@
import { Fragment } from "react";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// helper
import { renderEmoji } from "helpers/emoji.helper";
// icons
import { Icon } from "components/ui";
const reactionEmojis = [
"128077",
"128078",
"128516",
"128165",
"128533",
"129505",
"9992",
"128064",
];
interface Props {
size?: "sm" | "md" | "lg";
position?: "top" | "bottom";
value?: string | string[] | null;
onSelect: (emoji: string) => void;
}
export const ReactionSelector: React.FC<Props> = (props) => {
const { value, onSelect, position, size } = props;
return (
<Popover className="relative">
{({ open, close: closePopover }) => (
<>
<Popover.Button
className={`${
open ? "" : "text-opacity-90"
} group inline-flex items-center rounded-md bg-custom-background-80 focus:outline-none`}
>
<span
className={`flex justify-center items-center rounded-md px-2 ${
size === "sm" ? "w-6 h-6" : size === "md" ? "w-7 h-7" : "w-8 h-8"
}`}
>
<Icon iconName="add_reaction" className="text-custom-text-100 scale-125" />
</span>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel
className={`bg-custom-sidebar-background-100 absolute -left-2 z-10 ${
position === "top" ? "-top-12" : "-bottom-12"
}`}
>
<div className="bg-custom-sidebar-background-100 border border-custom-border-200 rounded-md p-1">
<div className="flex gap-x-1">
{reactionEmojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onSelect(emoji);
closePopover();
}}
className="flex select-none items-center justify-between rounded-md text-sm p-1 hover:bg-custom-sidebar-background-90"
>
{renderEmoji(emoji)}
</button>
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
};

View File

@ -1,5 +1,3 @@
import Link from "next/link";
// icons // icons
import { ArrowTopRightOnSquareIcon, LinkIcon, TrashIcon } from "@heroicons/react/24/outline"; import { ArrowTopRightOnSquareIcon, LinkIcon, TrashIcon } from "@heroicons/react/24/outline";
// helpers // helpers
@ -30,14 +28,14 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
<div key={link.id} className="relative"> <div key={link.id} className="relative">
{!isNotAllowed && ( {!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-[1] flex items-center gap-1"> <div className="absolute top-1.5 right-1.5 z-[1] flex items-center gap-1">
<Link href={link.url}> <a
<a href={link.url}
className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 outline-none hover:bg-custom-background-80" target="_blank"
target="_blank" rel="noopener noreferrer"
> className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 outline-none hover:bg-custom-background-80"
<ArrowTopRightOnSquareIcon className="h-4 w-4 text-custom-text-200" /> >
</a> <ArrowTopRightOnSquareIcon className="h-4 w-4 text-custom-text-200" />
</Link> </a>
<button <button
type="button" type="button"
className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 text-red-500 outline-none duration-300 hover:bg-red-500/20" className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 text-red-500 outline-none duration-300 hover:bg-red-500/20"
@ -47,27 +45,27 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
</button> </button>
</div> </div>
)} )}
<Link href={link.url}> <a
<a href={link.url}
className="relative flex gap-2 rounded-md bg-custom-background-90 p-2" target="_blank"
target="_blank" rel="noopener noreferrer"
> className="relative flex gap-2 rounded-md bg-custom-background-90 p-2"
<div className="mt-0.5"> >
<LinkIcon className="h-3.5 w-3.5" /> <div className="mt-0.5">
</div> <LinkIcon className="h-3.5 w-3.5" />
<div> </div>
<h5 className="w-4/5 break-words">{link.title}</h5> <div>
<p className="mt-0.5 text-custom-text-200"> <h5 className="w-4/5 break-words">{link.title ?? link.url}</h5>
Added {timeAgo(link.created_at)} <p className="mt-0.5 text-custom-text-200">
<br /> Added {timeAgo(link.created_at)}
by{" "} <br />
{link.created_by_detail.is_bot by{" "}
? link.created_by_detail.first_name + " Bot" {link.created_by_detail.is_bot
: link.created_by_detail.email} ? link.created_by_detail.first_name + " Bot"
</p> : link.created_by_detail.email}
</div> </p>
</a> </div>
</Link> </a>
</div> </div>
))} ))}
</> </>

View File

@ -0,0 +1,201 @@
import React, { useCallback } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// services
import stateService from "services/state.service";
// hooks
import useUser from "hooks/use-user";
import { useProjectMyMembership } from "contexts/project-member.context";
// components
import {
AllLists,
AllBoards,
CalendarView,
SpreadsheetView,
GanttChartView,
} from "components/core";
// ui
import { EmptyState, Spinner } from "components/ui";
// icons
import { TrashIcon } from "@heroicons/react/24/outline";
// images
import emptyIssue from "public/empty-state/issue.svg";
import emptyIssueArchive from "public/empty-state/issue-archive.svg";
// helpers
import { getStatesList } from "helpers/state.helper";
// types
import { IIssue, IIssueViewProps } from "types";
// fetch-keys
import { STATES_LIST } from "constants/fetch-keys";
type Props = {
addIssueToDate: (date: string) => void;
addIssueToGroup: (groupTitle: string) => void;
disableUserActions: boolean;
dragDisabled?: boolean;
emptyState: {
title: string;
description?: string;
primaryButton?: {
icon: any;
text: string;
onClick: () => void;
};
secondaryButton?: React.ReactNode;
};
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleOnDragEnd: (result: DropResult) => Promise<void>;
openIssuesListModal: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
trashBox: boolean;
setTrashBox: React.Dispatch<React.SetStateAction<boolean>>;
viewProps: IIssueViewProps;
};
export const AllViews: React.FC<Props> = ({
addIssueToDate,
addIssueToGroup,
disableUserActions,
dragDisabled = false,
emptyState,
handleIssueAction,
handleOnDragEnd,
openIssuesListModal,
removeIssue,
trashBox,
setTrashBox,
viewProps,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { user } = useUser();
const { memberRole } = useProjectMyMembership();
const { groupedIssues, isEmpty, issueView } = viewProps;
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups);
const handleTrashBox = useCallback(
(isDragging: boolean) => {
if (isDragging && !trashBox) setTrashBox(true);
},
[trashBox, setTrashBox]
);
return (
<DragDropContext onDragEnd={handleOnDragEnd}>
<StrictModeDroppable droppableId="trashBox">
{(provided, snapshot) => (
<div
className={`${
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
} fixed top-4 left-1/2 -translate-x-1/2 z-40 w-72 flex items-center justify-center gap-2 rounded border-2 border-red-500/20 bg-custom-background-100 px-3 py-5 text-xs font-medium italic text-red-500 ${
snapshot.isDraggingOver ? "bg-red-500 blur-2xl opacity-70" : ""
} transition duration-300`}
ref={provided.innerRef}
{...provided.droppableProps}
>
<TrashIcon className="h-4 w-4" />
Drop here to delete the issue.
</div>
)}
</StrictModeDroppable>
{groupedIssues ? (
!isEmpty || issueView === "kanban" || issueView === "calendar" ? (
<>
{issueView === "list" ? (
<AllLists
states={states}
addIssueToGroup={addIssueToGroup}
handleIssueAction={handleIssueAction}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
removeIssue={removeIssue}
disableUserActions={disableUserActions}
user={user}
userAuth={memberRole}
viewProps={viewProps}
/>
) : issueView === "kanban" ? (
<AllBoards
addIssueToGroup={addIssueToGroup}
disableUserActions={disableUserActions}
dragDisabled={dragDisabled}
handleIssueAction={handleIssueAction}
handleTrashBox={handleTrashBox}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
removeIssue={removeIssue}
states={states}
user={user}
userAuth={memberRole}
viewProps={viewProps}
/>
) : issueView === "calendar" ? (
<CalendarView
handleIssueAction={handleIssueAction}
addIssueToDate={addIssueToDate}
disableUserActions={disableUserActions}
user={user}
userAuth={memberRole}
/>
) : issueView === "spreadsheet" ? (
<SpreadsheetView
handleIssueAction={handleIssueAction}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
disableUserActions={disableUserActions}
user={user}
userAuth={memberRole}
/>
) : (
issueView === "gantt_chart" && <GanttChartView />
)}
</>
) : router.pathname.includes("archived-issues") ? (
<EmptyState
title="Archived Issues will be shown here"
description="All the issues that have been in the completed or canceled groups for the configured period of time can be viewed here."
image={emptyIssueArchive}
primaryButton={{
text: "Go to Automation Settings",
onClick: () => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`);
},
}}
/>
) : (
<EmptyState
title={emptyState.title}
description={emptyState.description}
image={emptyIssue}
primaryButton={
emptyState.primaryButton
? {
icon: emptyState.primaryButton.icon,
text: emptyState.primaryButton.text,
onClick: emptyState.primaryButton.onClick,
}
: undefined
}
secondaryButton={emptyState.secondaryButton}
/>
)
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</DragDropContext>
);
};

View File

@ -1,75 +1,66 @@
// hooks
import useProjectIssuesView from "hooks/use-issues-view";
// components // components
import { SingleBoard } from "components/core/board-view/single-board"; import { SingleBoard } from "components/core/views/board-view/single-board";
// icons // icons
import { getStateGroupIcon } from "components/icons"; import { getStateGroupIcon } from "components/icons";
// helpers // helpers
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
// types // types
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types"; import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types";
type Props = { type Props = {
type: "issue" | "cycle" | "module"; addIssueToGroup: (groupTitle: string) => void;
states: IState[] | undefined; disableUserActions: boolean;
addIssueToState: (groupTitle: string) => void; dragDisabled: boolean;
makeIssueCopy: (issue: IIssue) => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleEditIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
handleTrashBox: (isDragging: boolean) => void; handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null;
isCompleted?: boolean; states: IState[] | undefined;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
viewProps: IIssueViewProps;
}; };
export const AllBoards: React.FC<Props> = ({ export const AllBoards: React.FC<Props> = ({
type, addIssueToGroup,
states, disableUserActions,
addIssueToState, dragDisabled,
makeIssueCopy, handleIssueAction,
handleEditIssue,
openIssuesListModal,
handleDeleteIssue,
handleTrashBox, handleTrashBox,
openIssuesListModal,
removeIssue, removeIssue,
isCompleted = false, states,
user, user,
userAuth, userAuth,
viewProps,
}) => { }) => {
const { const { groupByProperty: selectedGroup, groupedIssues, showEmptyGroups } = viewProps;
groupedByIssues,
groupByProperty: selectedGroup,
showEmptyGroups,
} = useProjectIssuesView();
return ( return (
<> <>
{groupedByIssues ? ( {groupedIssues ? (
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8"> <div className="horizontal-scroll-enable flex h-full gap-x-4 p-8">
{Object.keys(groupedByIssues).map((singleGroup, index) => { {Object.keys(groupedIssues).map((singleGroup, index) => {
const currentState = const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null; if (!showEmptyGroups && groupedIssues[singleGroup].length === 0) return null;
return ( return (
<SingleBoard <SingleBoard
key={index} key={index}
type={type} addIssueToGroup={() => addIssueToGroup(singleGroup)}
currentState={currentState} currentState={currentState}
disableUserActions={disableUserActions}
dragDisabled={dragDisabled}
groupTitle={singleGroup} groupTitle={singleGroup}
handleEditIssue={handleEditIssue} handleIssueAction={handleIssueAction}
makeIssueCopy={makeIssueCopy}
addIssueToState={() => addIssueToState(singleGroup)}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={openIssuesListModal ?? null}
handleTrashBox={handleTrashBox} handleTrashBox={handleTrashBox}
openIssuesListModal={openIssuesListModal ?? null}
removeIssue={removeIssue} removeIssue={removeIssue}
isCompleted={isCompleted}
user={user} user={user}
userAuth={userAuth} userAuth={userAuth}
viewProps={viewProps}
/> />
); );
})} })}
@ -77,11 +68,11 @@ export const AllBoards: React.FC<Props> = ({
<div className="h-full w-96 flex-shrink-0 space-y-2 p-1"> <div className="h-full w-96 flex-shrink-0 space-y-2 p-1">
<h2 className="text-lg font-semibold">Hidden groups</h2> <h2 className="text-lg font-semibold">Hidden groups</h2>
<div className="space-y-3"> <div className="space-y-3">
{Object.keys(groupedByIssues).map((singleGroup, index) => { {Object.keys(groupedIssues).map((singleGroup, index) => {
const currentState = const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
if (groupedByIssues[singleGroup].length === 0) if (groupedIssues[singleGroup].length === 0)
return ( return (
<div <div
key={index} key={index}

View File

@ -8,7 +8,7 @@ import useSWR from "swr";
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
import projectService from "services/project.service"; import projectService from "services/project.service";
// hooks // hooks
import useIssuesView from "hooks/use-issues-view"; import useProjects from "hooks/use-projects";
// component // component
import { Avatar } from "components/ui"; import { Avatar } from "components/ui";
// icons // icons
@ -16,47 +16,56 @@ import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicon
import { getPriorityIcon, getStateGroupIcon } from "components/icons"; import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// helpers // helpers
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// types // types
import { IIssueLabels, IState } from "types"; import { IIssueViewProps, IState } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys"; import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = { type Props = {
currentState?: IState | null; currentState?: IState | null;
groupTitle: string; groupTitle: string;
addIssueToState: () => void; addIssueToGroup: () => void;
isCollapsed: boolean; isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>; setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
isCompleted?: boolean; disableUserActions: boolean;
viewProps: IIssueViewProps;
}; };
export const BoardHeader: React.FC<Props> = ({ export const BoardHeader: React.FC<Props> = ({
currentState, currentState,
groupTitle, groupTitle,
addIssueToState, addIssueToGroup,
isCollapsed, isCollapsed,
setIsCollapsed, setIsCollapsed,
isCompleted = false, disableUserActions,
viewProps,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { groupedByIssues, groupByProperty: selectedGroup } = useIssuesView(); const { groupedIssues, groupByProperty: selectedGroup } = viewProps;
const { data: issueLabels } = useSWR<IIssueLabels[]>( const { data: issueLabels } = useSWR(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, workspaceSlug && projectId && selectedGroup === "labels"
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId.toString())
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string) : null,
workspaceSlug && projectId && selectedGroup === "labels"
? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString())
: null : null
); );
const { data: members } = useSWR( const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, workspaceSlug && projectId && selectedGroup === "created_by"
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId.toString())
? () => projectService.projectMembers(workspaceSlug as string, projectId as string) : null,
workspaceSlug && projectId && selectedGroup === "created_by"
? () => projectService.projectMembers(workspaceSlug.toString(), projectId.toString())
: null : null
); );
const { projects } = useProjects();
const getGroupTitle = () => { const getGroupTitle = () => {
let title = addSpaceIfCamelCase(groupTitle); let title = addSpaceIfCamelCase(groupTitle);
@ -67,6 +76,9 @@ export const BoardHeader: React.FC<Props> = ({
case "labels": case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None"; title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
break; break;
case "project":
title = projects?.find((p) => p.id === groupTitle)?.name ?? "None";
break;
case "created_by": case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member; const member = members?.find((member) => member.member.id === groupTitle)?.member;
title = title =
@ -87,9 +99,22 @@ export const BoardHeader: React.FC<Props> = ({
icon = icon =
currentState && getStateGroupIcon(currentState.group, "16", "16", currentState.color); currentState && getStateGroupIcon(currentState.group, "16", "16", currentState.color);
break; break;
case "state_detail.group":
icon = getStateGroupIcon(groupTitle as any, "16", "16");
break;
case "priority": case "priority":
icon = getPriorityIcon(groupTitle, "text-lg"); icon = getPriorityIcon(groupTitle, "text-lg");
break; break;
case "project":
const project = projects?.find((p) => p.id === groupTitle);
icon =
project &&
(project.emoji !== null
? renderEmoji(project.emoji)
: project.icon_prop !== null
? renderEmoji(project.icon_prop)
: null);
break;
case "labels": case "labels":
const labelColor = const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000"; issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000";
@ -116,7 +141,7 @@ export const BoardHeader: React.FC<Props> = ({
!isCollapsed ? "flex-col rounded-md bg-custom-background-90" : "" !isCollapsed ? "flex-col rounded-md bg-custom-background-90" : ""
}`} }`}
> >
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}> <div className={`flex items-center ${isCollapsed ? "gap-1" : "flex-col gap-2"}`}>
<div <div
className={`flex cursor-pointer items-center gap-x-3 max-w-[316px] ${ className={`flex cursor-pointer items-center gap-x-3 max-w-[316px] ${
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : "" !isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
@ -126,7 +151,7 @@ export const BoardHeader: React.FC<Props> = ({
<h2 <h2
className="text-lg font-semibold capitalize truncate" className="text-lg font-semibold capitalize truncate"
style={{ style={{
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb", writingMode: isCollapsed ? "horizontal-tb" : "vertical-rl",
}} }}
> >
{getGroupTitle()} {getGroupTitle()}
@ -136,7 +161,7 @@ export const BoardHeader: React.FC<Props> = ({
isCollapsed ? "ml-0.5" : "" isCollapsed ? "ml-0.5" : ""
} min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs`} } min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs`}
> >
{groupedByIssues?.[groupTitle].length ?? 0} {groupedIssues?.[groupTitle].length ?? 0}
</span> </span>
</div> </div>
</div> </div>
@ -155,11 +180,11 @@ export const BoardHeader: React.FC<Props> = ({
<ArrowsPointingOutIcon className="h-4 w-4" /> <ArrowsPointingOutIcon className="h-4 w-4" />
)} )}
</button> </button>
{!isCompleted && selectedGroup !== "created_by" && ( {!disableUserActions && selectedGroup !== "created_by" && (
<button <button
type="button" type="button"
className="grid h-7 w-7 place-items-center rounded p-1 text-custom-text-200 outline-none duration-300 hover:bg-custom-background-80" className="grid h-7 w-7 place-items-center rounded p-1 text-custom-text-200 outline-none duration-300 hover:bg-custom-background-80"
onClick={addIssueToState} onClick={addIssueToGroup}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>

View File

@ -5,9 +5,6 @@ import { useRouter } from "next/router";
// react-beautiful-dnd // react-beautiful-dnd
import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { Draggable } from "react-beautiful-dnd"; import { Draggable } from "react-beautiful-dnd";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useIssuesProperties from "hooks/use-issue-properties";
// components // components
import { BoardHeader, SingleBoardIssue } from "components/core"; import { BoardHeader, SingleBoardIssue } from "components/core";
// ui // ui
@ -17,64 +14,63 @@ import { PlusIcon } from "@heroicons/react/24/outline";
// helpers // helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types // types
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types"; import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types";
type Props = { type Props = {
type?: "issue" | "cycle" | "module"; addIssueToGroup: () => void;
currentState?: IState | null; currentState?: IState | null;
disableUserActions: boolean;
dragDisabled: boolean;
groupTitle: string; groupTitle: string;
handleEditIssue: (issue: IIssue) => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
makeIssueCopy: (issue: IIssue) => void;
addIssueToState: () => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
handleTrashBox: (isDragging: boolean) => void; handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null;
isCompleted?: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
viewProps: IIssueViewProps;
}; };
export const SingleBoard: React.FC<Props> = ({ export const SingleBoard: React.FC<Props> = ({
type, addIssueToGroup,
currentState, currentState,
groupTitle, groupTitle,
handleEditIssue, disableUserActions,
makeIssueCopy, dragDisabled,
addIssueToState, handleIssueAction,
handleDeleteIssue,
openIssuesListModal,
handleTrashBox, handleTrashBox,
openIssuesListModal,
removeIssue, removeIssue,
isCompleted = false,
user, user,
userAuth, userAuth,
viewProps,
}) => { }) => {
// collapse/expand // collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true); const [isCollapsed, setIsCollapsed] = useState(true);
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssuesView(); const { groupedIssues, groupByProperty: selectedGroup, orderBy, properties } = viewProps;
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { cycleId, moduleId } = router.query;
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
// Check if it has at least 4 tickets since it is enough to accommodate the Calendar height // Check if it has at least 4 tickets since it is enough to accommodate the Calendar height
const issuesLength = groupedByIssues?.[groupTitle].length; const issuesLength = groupedIssues?.[groupTitle].length;
const hasMinimumNumberOfCards = issuesLength ? issuesLength >= 4 : false; const hasMinimumNumberOfCards = issuesLength ? issuesLength >= 4 : false;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
return ( return (
<div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}> <div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
<BoardHeader <BoardHeader
addIssueToState={addIssueToState} addIssueToGroup={addIssueToGroup}
currentState={currentState} currentState={currentState}
groupTitle={groupTitle} groupTitle={groupTitle}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed} setIsCollapsed={setIsCollapsed}
isCompleted={isCompleted} disableUserActions={disableUserActions}
viewProps={viewProps}
/> />
{isCollapsed && ( {isCollapsed && (
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}> <StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
@ -112,14 +108,12 @@ export const SingleBoard: React.FC<Props> = ({
hasMinimumNumberOfCards ? "overflow-hidden overflow-y-scroll" : "" hasMinimumNumberOfCards ? "overflow-hidden overflow-y-scroll" : ""
} `} } `}
> >
{groupedByIssues?.[groupTitle].map((issue, index) => ( {groupedIssues?.[groupTitle].map((issue, index) => (
<Draggable <Draggable
key={issue.id} key={issue.id}
draggableId={issue.id} draggableId={issue.id}
index={index} index={index}
isDragDisabled={ isDragDisabled={isNotAllowed || dragDisabled}
isNotAllowed || selectedGroup === "created_by" || selectedGroup === "labels"
}
> >
{(provided, snapshot) => ( {(provided, snapshot) => (
<SingleBoardIssue <SingleBoardIssue
@ -128,21 +122,20 @@ export const SingleBoard: React.FC<Props> = ({
snapshot={snapshot} snapshot={snapshot}
type={type} type={type}
index={index} index={index}
selectedGroup={selectedGroup}
issue={issue} issue={issue}
groupTitle={groupTitle} groupTitle={groupTitle}
properties={properties} editIssue={() => handleIssueAction(issue, "edit")}
editIssue={() => handleEditIssue(issue)} makeIssueCopy={() => handleIssueAction(issue, "copy")}
makeIssueCopy={() => makeIssueCopy(issue)} handleDeleteIssue={() => handleIssueAction(issue, "delete")}
handleDeleteIssue={handleDeleteIssue}
handleTrashBox={handleTrashBox} handleTrashBox={handleTrashBox}
removeIssue={() => { removeIssue={() => {
if (removeIssue && issue.bridge_id) if (removeIssue && issue.bridge_id)
removeIssue(issue.bridge_id, issue.id); removeIssue(issue.bridge_id, issue.id);
}} }}
isCompleted={isCompleted} disableUserActions={disableUserActions}
user={user} user={user}
userAuth={userAuth} userAuth={userAuth}
viewProps={viewProps}
/> />
)} )}
</Draggable> </Draggable>
@ -161,18 +154,18 @@ export const SingleBoard: React.FC<Props> = ({
<button <button
type="button" type="button"
className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1" className="flex items-center gap-2 font-medium text-custom-primary outline-none p-1"
onClick={addIssueToState} onClick={addIssueToGroup}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Add Issue Add Issue
</button> </button>
) : ( ) : (
!isCompleted && ( !disableUserActions && (
<CustomMenu <CustomMenu
customButton={ customButton={
<button <button
type="button" type="button"
className="flex items-center gap-2 font-medium text-custom-primary outline-none" className="flex items-center gap-2 font-medium text-custom-primary outline-none whitespace-nowrap"
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Add Issue Add Issue
@ -181,7 +174,7 @@ export const SingleBoard: React.FC<Props> = ({
position="left" position="left"
noBorder noBorder
> >
<CustomMenu.MenuItem onClick={addIssueToState}> <CustomMenu.MenuItem onClick={addIssueToGroup}>
Create new Create new
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{openIssuesListModal && ( {openIssuesListModal && (

View File

@ -15,7 +15,6 @@ import {
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
// hooks // hooks
import useIssuesView from "hooks/use-issues-view";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useOutsideClickDetector from "hooks/use-outside-click-detector"; import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components // components
@ -23,7 +22,7 @@ import {
ViewAssigneeSelect, ViewAssigneeSelect,
ViewDueDateSelect, ViewDueDateSelect,
ViewEstimateSelect, ViewEstimateSelect,
ViewLabelSelect, ViewIssueLabel,
ViewPrioritySelect, ViewPrioritySelect,
ViewStateSelect, ViewStateSelect,
} from "components/issues"; } from "components/issues";
@ -45,42 +44,26 @@ import { LayerDiagonalIcon } from "components/icons";
import { handleIssuesMutation } from "constants/issue"; import { handleIssuesMutation } from "constants/issue";
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import { import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types";
ICurrentUserResponse,
IIssue,
ISubIssueResponse,
Properties,
TIssueGroupByOptions,
UserAuth,
} from "types";
// fetch-keys // fetch-keys
import { import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys";
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
VIEW_ISSUES,
} from "constants/fetch-keys";
type Props = { type Props = {
type?: string; type?: string;
provided: DraggableProvided; provided: DraggableProvided;
snapshot: DraggableStateSnapshot; snapshot: DraggableStateSnapshot;
issue: IIssue; issue: IIssue;
properties: Properties;
groupTitle?: string; groupTitle?: string;
index: number; index: number;
selectedGroup: TIssueGroupByOptions;
editIssue: () => void; editIssue: () => void;
makeIssueCopy: () => void; makeIssueCopy: () => void;
removeIssue?: (() => void) | null; removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void;
handleTrashBox: (isDragging: boolean) => void; handleTrashBox: (isDragging: boolean) => void;
isCompleted?: boolean; disableUserActions: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
viewProps: IIssueViewProps;
}; };
export const SingleBoardIssue: React.FC<Props> = ({ export const SingleBoardIssue: React.FC<Props> = ({
@ -88,18 +71,17 @@ export const SingleBoardIssue: React.FC<Props> = ({
provided, provided,
snapshot, snapshot,
issue, issue,
properties,
index, index,
selectedGroup,
editIssue, editIssue,
makeIssueCopy, makeIssueCopy,
removeIssue, removeIssue,
groupTitle, groupTitle,
handleDeleteIssue, handleDeleteIssue,
handleTrashBox, handleTrashBox,
isCompleted = false, disableUserActions,
user, user,
userAuth, userAuth,
viewProps,
}) => { }) => {
// context menu // context menu
const [contextMenu, setContextMenu] = useState(false); const [contextMenu, setContextMenu] = useState(false);
@ -108,24 +90,16 @@ export const SingleBoardIssue: React.FC<Props> = ({
const actionSectionRef = useRef<HTMLDivElement | null>(null); const actionSectionRef = useRef<HTMLDivElement | null>(null);
const { orderBy, params } = useIssuesView(); const { groupByProperty: selectedGroup, orderBy, properties, mutateIssues } = viewProps;
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback( const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issue: IIssue) => { (formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !issue) return;
const fetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: viewId
? VIEW_ISSUES(viewId.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
if (issue.parent) { if (issue.parent) {
mutate<ISubIssueResponse>( mutate<ISubIssueResponse>(
@ -149,13 +123,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
false false
); );
} else { } else {
mutate< mutateIssues(
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
fetchKey,
(prevData) => (prevData) =>
handleIssuesMutation( handleIssuesMutation(
formData, formData,
@ -170,9 +138,9 @@ export const SingleBoardIssue: React.FC<Props> = ({
} }
issuesService issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user) .patchIssue(workspaceSlug as string, issue.project, issue.id, formData, user)
.then(() => { .then(() => {
mutate(fetchKey); mutateIssues();
if (cycleId) mutate(CYCLE_DETAILS(cycleId as string)); if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
if (moduleId) mutate(MODULE_DETAILS(moduleId as string)); if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
@ -180,15 +148,13 @@ export const SingleBoardIssue: React.FC<Props> = ({
}, },
[ [
workspaceSlug, workspaceSlug,
projectId,
cycleId, cycleId,
moduleId, moduleId,
viewId,
groupTitle, groupTitle,
index, index,
selectedGroup, selectedGroup,
mutateIssues,
orderBy, orderBy,
params,
user, user,
] ]
); );
@ -228,7 +194,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
return ( return (
<> <>
@ -255,7 +221,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
Copy issue link Copy issue link
</ContextMenu.Item> </ContextMenu.Item>
<a <a
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`} href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
@ -365,13 +331,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
/> />
)} )}
{properties.labels && issue.labels.length > 0 && ( {properties.labels && issue.labels.length > 0 && (
<ViewLabelSelect <ViewIssueLabel issue={issue} maxRender={2} />
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
user={user}
selfPositioned
/>
)} )}
{properties.assignee && ( {properties.assignee && (
<ViewAssigneeSelect <ViewAssigneeSelect

View File

@ -34,19 +34,17 @@ import {
} from "constants/fetch-keys"; } from "constants/fetch-keys";
type Props = { type Props = {
handleEditIssue: (issue: IIssue) => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDeleteIssue: (issue: IIssue) => void;
addIssueToDate: (date: string) => void; addIssueToDate: (date: string) => void;
isCompleted: boolean; disableUserActions: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
}; };
export const CalendarView: React.FC<Props> = ({ export const CalendarView: React.FC<Props> = ({
handleEditIssue, handleIssueAction,
handleDeleteIssue,
addIssueToDate, addIssueToDate,
isCompleted = false, disableUserActions,
user, user,
userAuth, userAuth,
}) => { }) => {
@ -167,7 +165,7 @@ export const CalendarView: React.FC<Props> = ({
); );
}, [currentDate]); }, [currentDate]);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted; const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
return calendarIssues ? ( return calendarIssues ? (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
@ -220,10 +218,10 @@ export const CalendarView: React.FC<Props> = ({
> >
{currentViewDaysData.map((date, index) => ( {currentViewDaysData.map((date, index) => (
<SingleCalendarDate <SingleCalendarDate
key={`${date}-${index}`}
index={index} index={index}
date={date} date={date}
handleEditIssue={handleEditIssue} handleIssueAction={handleIssueAction}
handleDeleteIssue={handleDeleteIssue}
addIssueToDate={addIssueToDate} addIssueToDate={addIssueToDate}
isMonthlyView={isMonthlyView} isMonthlyView={isMonthlyView}
showWeekEnds={showWeekEnds} showWeekEnds={showWeekEnds}

View File

@ -13,8 +13,7 @@ import { formatDate } from "helpers/calendar.helper";
import { ICurrentUserResponse, IIssue } from "types"; import { ICurrentUserResponse, IIssue } from "types";
type Props = { type Props = {
handleEditIssue: (issue: IIssue) => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDeleteIssue: (issue: IIssue) => void;
index: number; index: number;
date: { date: {
date: string; date: string;
@ -28,8 +27,7 @@ type Props = {
}; };
export const SingleCalendarDate: React.FC<Props> = ({ export const SingleCalendarDate: React.FC<Props> = ({
handleEditIssue, handleIssueAction,
handleDeleteIssue,
date, date,
index, index,
addIssueToDate, addIssueToDate,
@ -72,8 +70,8 @@ export const SingleCalendarDate: React.FC<Props> = ({
provided={provided} provided={provided}
snapshot={snapshot} snapshot={snapshot}
issue={issue} issue={issue}
handleEditIssue={handleEditIssue} handleEditIssue={() => handleIssueAction(issue, "edit")}
handleDeleteIssue={handleDeleteIssue} handleDeleteIssue={() => handleIssueAction(issue, "delete")}
user={user} user={user}
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
/> />

View File

@ -192,7 +192,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
</CustomMenu> </CustomMenu>
</div> </div>
)} )}
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail.id}/issues/${issue.id}`}> <Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="flex w-full cursor-pointer flex-col items-start justify-center gap-1.5"> <a className="flex w-full cursor-pointer flex-col items-start justify-center gap-1.5">
{properties.key && ( {properties.key && (
<Tooltip <Tooltip

View File

@ -2,7 +2,7 @@ import { useRouter } from "next/router";
// components // components
import { CycleIssuesGanttChartView } from "components/cycles"; import { CycleIssuesGanttChartView } from "components/cycles";
import { IssueGanttChartView } from "components/issues/gantt-chart"; import { IssueGanttChartView } from "components/issues";
import { ModuleIssuesGanttChartView } from "components/modules"; import { ModuleIssuesGanttChartView } from "components/modules";
import { ViewIssuesGanttChartView } from "components/views"; import { ViewIssuesGanttChartView } from "components/views";

View File

@ -0,0 +1,7 @@
export * from "./board-view";
export * from "./calendar-view";
export * from "./gantt-chart-view";
export * from "./list-view";
export * from "./spreadsheet-view";
export * from "./all-views";
export * from "./issues-view";

View File

@ -5,43 +5,31 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// react-beautiful-dnd // react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd"; import { DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
import stateService from "services/state.service"; import stateService from "services/state.service";
import modulesService from "services/modules.service"; import modulesService from "services/modules.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
// contexts
import { useProjectMyMembership } from "contexts/project-member.context";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view"; import useIssuesView from "hooks/use-issues-view";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
import useIssuesProperties from "hooks/use-issue-properties";
import useProjectMembers from "hooks/use-project-members";
// components // components
import { import { FiltersList, AllViews } from "components/core";
AllLists,
AllBoards,
FilterList,
CalendarView,
GanttChartView,
SpreadsheetView,
} from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { CreateUpdateViewModal } from "components/views"; import { CreateUpdateViewModal } from "components/views";
import { TransferIssues, TransferIssuesModal } from "components/cycles";
// ui // ui
import { EmptyState, PrimaryButton, Spinner, Icon } from "components/ui"; import { PrimaryButton, SecondaryButton } from "components/ui";
// icons // icons
import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline"; import { PlusIcon } from "@heroicons/react/24/outline";
// images
import emptyIssue from "public/empty-state/issue.svg";
import emptyIssueArchive from "public/empty-state/issue-archive.svg";
// helpers // helpers
import { getStatesList } from "helpers/state.helper"; import { getStatesList } from "helpers/state.helper";
import { orderArrayBy } from "helpers/array.helper"; import { orderArrayBy } from "helpers/array.helper";
// types // types
import { IIssue, IIssueFilterOptions } from "types"; import { IIssue, IIssueFilterOptions, IState } from "types";
// fetch-keys // fetch-keys
import { import {
CYCLE_DETAILS, CYCLE_DETAILS,
@ -49,19 +37,18 @@ import {
MODULE_DETAILS, MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS,
PROJECT_ISSUE_LABELS,
STATES_LIST, STATES_LIST,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
type Props = { type Props = {
type?: "issue" | "cycle" | "module";
openIssuesListModal?: () => void; openIssuesListModal?: () => void;
isCompleted?: boolean; disableUserActions?: boolean;
}; };
export const IssuesView: React.FC<Props> = ({ export const IssuesView: React.FC<Props> = ({
type = "issue",
openIssuesListModal, openIssuesListModal,
isCompleted = false, disableUserActions = false,
}) => { }) => {
// create issue modal // create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false); const [createIssueModal, setCreateIssueModal] = useState(false);
@ -83,20 +70,16 @@ export const IssuesView: React.FC<Props> = ({
// trash box // trash box
const [trashBox, setTrashBox] = useState(false); const [trashBox, setTrashBox] = useState(false);
// transfer issue
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { memberRole } = useProjectMyMembership();
const { user } = useUserAuth(); const { user } = useUserAuth();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { const {
groupedByIssues, groupedByIssues,
mutateIssues,
issueView, issueView,
groupByProperty: selectedGroup, groupByProperty: selectedGroup,
orderBy, orderBy,
@ -104,7 +87,9 @@ export const IssuesView: React.FC<Props> = ({
isEmpty, isEmpty,
setFilters, setFilters,
params, params,
showEmptyGroups,
} = useIssuesView(); } = useIssuesView();
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const { data: stateGroups } = useSWR( const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
@ -112,7 +97,16 @@ export const IssuesView: React.FC<Props> = ({
? () => stateService.getStates(workspaceSlug as string, projectId as string) ? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null : null
); );
const states = getStatesList(stateGroups ?? {}); const states = getStatesList(stateGroups);
const { data: labels } = useSWR(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString())
: null
);
const { members } = useProjectMembers(workspaceSlug?.toString(), projectId?.toString());
const handleDeleteIssue = useCallback( const handleDeleteIssue = useCallback(
(issue: IIssue) => { (issue: IIssue) => {
@ -123,7 +117,7 @@ export const IssuesView: React.FC<Props> = ({
); );
const handleOnDragEnd = useCallback( const handleOnDragEnd = useCallback(
(result: DropResult) => { async (result: DropResult) => {
setTrashBox(false); setTrashBox(false);
if (!result.destination || !workspaceSlug || !projectId || !groupedByIssues) return; if (!result.destination || !workspaceSlug || !projectId || !groupedByIssues) return;
@ -191,7 +185,10 @@ export const IssuesView: React.FC<Props> = ({
// dragged item(or issue) // dragged item(or issue)
if (selectedGroup === "priority") draggedItem.priority = destinationGroup; if (selectedGroup === "priority") draggedItem.priority = destinationGroup;
else if (selectedGroup === "state") draggedItem.state = destinationGroup; else if (selectedGroup === "state") {
draggedItem.state = destinationGroup;
draggedItem.state_detail = states?.find((s) => s.id === destinationGroup) as IState;
}
} }
const sourceGroup = source.droppableId; // source group id const sourceGroup = source.droppableId; // source group id
@ -207,8 +204,8 @@ export const IssuesView: React.FC<Props> = ({
(prevData) => { (prevData) => {
if (!prevData) return prevData; if (!prevData) return prevData;
const sourceGroupArray = prevData[sourceGroup]; const sourceGroupArray = [...groupedByIssues[sourceGroup]];
const destinationGroupArray = groupedByIssues[destinationGroup]; const destinationGroupArray = [...groupedByIssues[destinationGroup]];
sourceGroupArray.splice(source.index, 1); sourceGroupArray.splice(source.index, 1);
destinationGroupArray.splice(destination.index, 0, draggedItem); destinationGroupArray.splice(destination.index, 0, draggedItem);
@ -236,7 +233,9 @@ export const IssuesView: React.FC<Props> = ({
user user
) )
.then((response) => { .then((response) => {
const sourceStateBeforeDrag = states.find((state) => state.name === source.droppableId); const sourceStateBeforeDrag = states?.find(
(state) => state.name === source.droppableId
);
if ( if (
sourceStateBeforeDrag?.group !== "completed" && sourceStateBeforeDrag?.group !== "completed" &&
@ -281,7 +280,7 @@ export const IssuesView: React.FC<Props> = ({
] ]
); );
const addIssueToState = useCallback( const addIssueToGroup = useCallback(
(groupTitle: string) => { (groupTitle: string) => {
setCreateIssueModal(true); setCreateIssueModal(true);
@ -335,6 +334,15 @@ export const IssuesView: React.FC<Props> = ({
[setEditIssueModal, setIssueToEdit] [setEditIssueModal, setIssueToEdit]
); );
const handleIssueAction = useCallback(
(issue: IIssue, action: "copy" | "edit" | "delete") => {
if (action === "copy") makeIssueCopy(issue);
else if (action === "edit") handleEditIssue(issue);
else if (action === "delete") handleDeleteIssue(issue);
},
[makeIssueCopy, handleEditIssue, handleDeleteIssue]
);
const removeIssueFromCycle = useCallback( const removeIssueFromCycle = useCallback(
(bridgeId: string, issueId: string) => { (bridgeId: string, issueId: string) => {
if (!workspaceSlug || !projectId || !cycleId) return; if (!workspaceSlug || !projectId || !cycleId) return;
@ -421,13 +429,6 @@ export const IssuesView: React.FC<Props> = ({
[workspaceSlug, projectId, moduleId, params, selectedGroup, setToastAlert] [workspaceSlug, projectId, moduleId, params, selectedGroup, setToastAlert]
); );
const handleTrashBox = useCallback(
(isDragging: boolean) => {
if (isDragging && !trashBox) setTrashBox(true);
},
[trashBox, setTrashBox]
);
const nullFilters = Object.keys(filters).filter( const nullFilters = Object.keys(filters).filter(
(key) => filters[key as keyof IIssueFilterOptions] === null (key) => filters[key as keyof IIssueFilterOptions] === null
); );
@ -461,14 +462,27 @@ export const IssuesView: React.FC<Props> = ({
data={issueToDelete} data={issueToDelete}
user={user} user={user}
/> />
<TransferIssuesModal
handleClose={() => setTransferIssuesModal(false)}
isOpen={transferIssuesModal}
/>
{areFiltersApplied && ( {areFiltersApplied && (
<> <>
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0"> <div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
<FilterList filters={filters} setFilters={setFilters} /> <FiltersList
filters={filters}
setFilters={(updatedFilter) => setFilters(updatedFilter, !Boolean(viewId))}
labels={labels}
members={members?.map((m) => m.member)}
states={states}
clearAllFilters={() =>
setFilters({
assignees: null,
created_by: null,
labels: null,
priority: null,
state: null,
target_date: null,
type: null,
})
}
/>
<PrimaryButton <PrimaryButton
onClick={() => { onClick={() => {
if (viewId) { if (viewId) {
@ -492,129 +506,62 @@ export const IssuesView: React.FC<Props> = ({
{<div className="mt-3 border-t border-custom-border-200" />} {<div className="mt-3 border-t border-custom-border-200" />}
</> </>
)} )}
<AllViews
<DragDropContext onDragEnd={handleOnDragEnd}> addIssueToDate={addIssueToDate}
<StrictModeDroppable droppableId="trashBox"> addIssueToGroup={addIssueToGroup}
{(provided, snapshot) => ( disableUserActions={disableUserActions}
<div dragDisabled={
className={`${ selectedGroup === "created_by" ||
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0" selectedGroup === "labels" ||
} fixed top-4 left-1/2 -translate-x-1/2 z-40 w-72 flex items-center justify-center gap-2 rounded border-2 border-red-500/20 bg-custom-background-100 px-3 py-5 text-xs font-medium italic text-red-500 ${ selectedGroup === "state_detail.group"
snapshot.isDraggingOver ? "bg-red-500 blur-2xl opacity-70" : "" }
} transition duration-300`} emptyState={{
ref={provided.innerRef} title: cycleId
{...provided.droppableProps} ? "Cycle issues will appear here"
> : moduleId
<TrashIcon className="h-4 w-4" /> ? "Module issues will appear here"
Drop here to delete the issue. : "Project issues will appear here",
</div> description:
)} "Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done.",
</StrictModeDroppable> primaryButton: {
{groupedByIssues ? ( icon: <PlusIcon className="h-4 w-4" />,
!isEmpty || issueView === "kanban" || issueView === "calendar" ? ( text: "New Issue",
<> onClick: () => {
{isCompleted && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />} const e = new KeyboardEvent("keydown", {
{issueView === "list" ? ( key: "c",
<AllLists });
type={type} document.dispatchEvent(e);
states={states} },
addIssueToState={addIssueToState} },
makeIssueCopy={makeIssueCopy} secondaryButton:
handleEditIssue={handleEditIssue} cycleId || moduleId ? (
handleDeleteIssue={handleDeleteIssue} <SecondaryButton
openIssuesListModal={type !== "issue" ? openIssuesListModal : null} className="flex items-center gap-1.5"
removeIssue={ onClick={openIssuesListModal ?? (() => {})}
type === "cycle" >
? removeIssueFromCycle <PlusIcon className="h-4 w-4" />
: type === "module" Add an existing issue
? removeIssueFromModule </SecondaryButton>
: null ) : null,
} }}
isCompleted={isCompleted} handleOnDragEnd={handleOnDragEnd}
user={user} handleIssueAction={handleIssueAction}
userAuth={memberRole} openIssuesListModal={openIssuesListModal ? openIssuesListModal : null}
/> removeIssue={cycleId ? removeIssueFromCycle : moduleId ? removeIssueFromModule : null}
) : issueView === "kanban" ? ( trashBox={trashBox}
<AllBoards setTrashBox={setTrashBox}
type={type} viewProps={{
states={states} groupByProperty: selectedGroup,
addIssueToState={addIssueToState} groupedIssues: groupedByIssues,
makeIssueCopy={makeIssueCopy} isEmpty,
handleEditIssue={handleEditIssue} issueView,
openIssuesListModal={type !== "issue" ? openIssuesListModal : null} mutateIssues,
handleDeleteIssue={handleDeleteIssue} orderBy,
handleTrashBox={handleTrashBox} params,
removeIssue={ properties,
type === "cycle" showEmptyGroups,
? removeIssueFromCycle }}
: type === "module" />
? removeIssueFromModule
: null
}
isCompleted={isCompleted}
user={user}
userAuth={memberRole}
/>
) : issueView === "calendar" ? (
<CalendarView
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
addIssueToDate={addIssueToDate}
isCompleted={isCompleted}
user={user}
userAuth={memberRole}
/>
) : issueView === "spreadsheet" ? (
<SpreadsheetView
type={type}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
isCompleted={isCompleted}
user={user}
userAuth={memberRole}
/>
) : (
issueView === "gantt_chart" && <GanttChartView />
)}
</>
) : router.pathname.includes("archived-issues") ? (
<EmptyState
title="Archived Issues will be shown here"
description="All the issues that have been in the completed or canceled groups for the configured period of time can be viewed here."
image={emptyIssueArchive}
buttonText="Go to Automation Settings"
onClick={() => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`);
}}
/>
) : (
<EmptyState
title={
cycleId
? "Cycle issues will appear here"
: moduleId
? "Module issues will appear here"
: "Project issues will appear here"
}
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
buttonText="New Issue"
buttonIcon={<PlusIcon className="h-4 w-4" />}
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
}}
/>
)
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</DragDropContext>
</> </>
); );
}; };

View File

@ -0,0 +1,62 @@
// components
import { SingleList } from "components/core/views/list-view/single-list";
// types
import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types";
// types
type Props = {
states: IState[] | undefined;
addIssueToGroup: (groupTitle: string) => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
viewProps: IIssueViewProps;
};
export const AllLists: React.FC<Props> = ({
addIssueToGroup,
handleIssueAction,
disableUserActions,
openIssuesListModal,
removeIssue,
states,
user,
userAuth,
viewProps,
}) => {
const { groupByProperty: selectedGroup, groupedIssues, showEmptyGroups } = viewProps;
return (
<>
{groupedIssues && (
<div className="h-full overflow-y-auto">
{Object.keys(groupedIssues).map((singleGroup) => {
const currentState =
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
if (!showEmptyGroups && groupedIssues[singleGroup].length === 0) return null;
return (
<SingleList
key={singleGroup}
groupTitle={singleGroup}
currentState={currentState}
addIssueToGroup={() => addIssueToGroup(singleGroup)}
handleIssueAction={handleIssueAction}
openIssuesListModal={openIssuesListModal}
removeIssue={removeIssue}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
viewProps={viewProps}
/>
);
})}
</div>
)}
</>
);
};

View File

@ -14,12 +14,10 @@ import {
ViewAssigneeSelect, ViewAssigneeSelect,
ViewDueDateSelect, ViewDueDateSelect,
ViewEstimateSelect, ViewEstimateSelect,
ViewLabelSelect, ViewIssueLabel,
ViewPrioritySelect, ViewPrioritySelect,
ViewStateSelect, ViewStateSelect,
} from "components/issues/view-select"; } from "components/issues";
// hooks
import useIssueView from "hooks/use-issues-view";
// ui // ui
import { Tooltip, CustomMenu, ContextMenu } from "components/ui"; import { Tooltip, CustomMenu, ContextMenu } from "components/ui";
// icons // icons
@ -37,70 +35,54 @@ import { LayerDiagonalIcon } from "components/icons";
import { copyTextToClipboard, truncateText } from "helpers/string.helper"; import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import { handleIssuesMutation } from "constants/issue"; import { handleIssuesMutation } from "constants/issue";
// types // types
import { ICurrentUserResponse, IIssue, ISubIssueResponse, Properties, UserAuth } from "types"; import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types";
// fetch-keys // fetch-keys
import { import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys";
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
VIEW_ISSUES,
} from "constants/fetch-keys";
type Props = { type Props = {
type?: string; type?: string;
issue: IIssue; issue: IIssue;
properties: Properties;
groupTitle?: string; groupTitle?: string;
editIssue: () => void; editIssue: () => void;
index: number; index: number;
makeIssueCopy: () => void; makeIssueCopy: () => void;
removeIssue?: (() => void) | null; removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void;
isCompleted?: boolean; disableUserActions: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
viewProps: IIssueViewProps;
}; };
export const SingleListIssue: React.FC<Props> = ({ export const SingleListIssue: React.FC<Props> = ({
type, type,
issue, issue,
properties,
editIssue, editIssue,
index, index,
makeIssueCopy, makeIssueCopy,
removeIssue, removeIssue,
groupTitle, groupTitle,
handleDeleteIssue, handleDeleteIssue,
isCompleted = false, disableUserActions,
user, user,
userAuth, userAuth,
viewProps,
}) => { }) => {
// context menu // context menu
const [contextMenu, setContextMenu] = useState(false); const [contextMenu, setContextMenu] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues"); const isArchivedIssues = router.pathname.includes("archived-issues");
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { groupByProperty: selectedGroup, orderBy, params } = useIssueView(); const { groupByProperty: selectedGroup, orderBy, properties, mutateIssues } = viewProps;
const partialUpdateIssue = useCallback( const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issue: IIssue) => { (formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !issue) return;
const fetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: viewId
? VIEW_ISSUES(viewId.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
if (issue.parent) { if (issue.parent) {
mutate<ISubIssueResponse>( mutate<ISubIssueResponse>(
@ -124,13 +106,7 @@ export const SingleListIssue: React.FC<Props> = ({
false false
); );
} else { } else {
mutate< mutateIssues(
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
fetchKey,
(prevData) => (prevData) =>
handleIssuesMutation( handleIssuesMutation(
formData, formData,
@ -145,9 +121,9 @@ export const SingleListIssue: React.FC<Props> = ({
} }
issuesService issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user) .patchIssue(workspaceSlug as string, issue.project, issue.id, formData, user)
.then(() => { .then(() => {
mutate(fetchKey); mutateIssues();
if (cycleId) mutate(CYCLE_DETAILS(cycleId as string)); if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
if (moduleId) mutate(MODULE_DETAILS(moduleId as string)); if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
@ -155,15 +131,13 @@ export const SingleListIssue: React.FC<Props> = ({
}, },
[ [
workspaceSlug, workspaceSlug,
projectId,
cycleId, cycleId,
moduleId, moduleId,
viewId,
groupTitle, groupTitle,
index, index,
selectedGroup, selectedGroup,
mutateIssues,
orderBy, orderBy,
params,
user, user,
] ]
); );
@ -182,11 +156,12 @@ export const SingleListIssue: React.FC<Props> = ({
}); });
}; };
const singleIssuePath = isArchivedIssues const issuePath = isArchivedIssues
? `/${workspaceSlug}/projects/${projectId}/archived-issues/${issue.id}` ? `/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}`
: `/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`; : `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted || isArchivedIssues; const isNotAllowed =
userAuth.isGuest || userAuth.isViewer || disableUserActions || isArchivedIssues;
return ( return (
<> <>
@ -212,22 +187,22 @@ export const SingleListIssue: React.FC<Props> = ({
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}> <ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link Copy issue link
</ContextMenu.Item> </ContextMenu.Item>
<a href={singleIssuePath} target="_blank" rel="noreferrer noopener"> <a href={issuePath} target="_blank" rel="noreferrer noopener">
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}> <ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab Open issue in new tab
</ContextMenu.Item> </ContextMenu.Item>
</a> </a>
</ContextMenu> </ContextMenu>
<div <div
className="flex flex-wrap items-center justify-between px-4 py-2.5 gap-2 border-b border-custom-border-200 bg-custom-background-100 last:border-b-0" className="flex items-center justify-between px-4 py-2.5 gap-10 border-b border-custom-border-200 bg-custom-background-100 last:border-b-0"
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
setContextMenu(true); setContextMenu(true);
setContextMenuPosition({ x: e.pageX, y: e.pageY }); setContextMenuPosition({ x: e.pageX, y: e.pageY });
}} }}
> >
<Link href={singleIssuePath}> <div className="flex-grow cursor-pointer min-w-[200px] whitespace-nowrap overflow-hidden overflow-ellipsis">
<div className="flex-grow cursor-pointer"> <Link href={issuePath}>
<a className="group relative flex items-center gap-2"> <a className="group relative flex items-center gap-2">
{properties.key && ( {properties.key && (
<Tooltip <Tooltip
@ -240,16 +215,14 @@ export const SingleListIssue: React.FC<Props> = ({
</Tooltip> </Tooltip>
)} )}
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}> <Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-[0.825rem] text-custom-text-100"> <span className="truncate text-[0.825rem] text-custom-text-100">{issue.name}</span>
{truncateText(issue.name, 50)}
</span>
</Tooltip> </Tooltip>
</a> </a>
</div> </Link>
</Link> </div>
<div <div
className={`flex w-full flex-shrink flex-wrap items-center gap-2 text-xs sm:w-auto ${ className={`flex flex-shrink-0 items-center gap-2 text-xs ${
isArchivedIssues ? "opacity-60" : "" isArchivedIssues ? "opacity-60" : ""
}`} }`}
> >
@ -279,15 +252,7 @@ export const SingleListIssue: React.FC<Props> = ({
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
/> />
)} )}
{properties.labels && issue.labels.length > 0 && ( {properties.labels && <ViewIssueLabel issue={issue} maxRender={3} />}
<ViewLabelSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="right"
user={user}
isNotAllowed={isNotAllowed}
/>
)}
{properties.assignee && ( {properties.assignee && (
<ViewAssigneeSelect <ViewAssigneeSelect
issue={issue} issue={issue}

View File

@ -8,7 +8,7 @@ import { Disclosure, Transition } from "@headlessui/react";
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
import projectService from "services/project.service"; import projectService from "services/project.service";
// hooks // hooks
import useIssuesProperties from "hooks/use-issue-properties"; import useProjects from "hooks/use-projects";
// components // components
import { SingleListIssue } from "components/core"; import { SingleListIssue } from "components/core";
// ui // ui
@ -18,58 +18,52 @@ import { PlusIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon, getStateGroupIcon } from "components/icons"; import { getPriorityIcon, getStateGroupIcon } from "components/icons";
// helpers // helpers
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// types // types
import { import {
ICurrentUserResponse, ICurrentUserResponse,
IIssue, IIssue,
IIssueLabels, IIssueLabels,
IIssueViewProps,
IState, IState,
TIssueGroupByOptions,
UserAuth, UserAuth,
} from "types"; } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys"; import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = { type Props = {
type?: "issue" | "cycle" | "module";
currentState?: IState | null; currentState?: IState | null;
groupTitle: string; groupTitle: string;
groupedByIssues: { addIssueToGroup: () => void;
[key: string]: IIssue[]; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
};
selectedGroup: TIssueGroupByOptions;
addIssueToState: () => void;
makeIssueCopy: (issue: IIssue) => void;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null;
isCompleted?: boolean; disableUserActions: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
viewProps: IIssueViewProps;
}; };
export const SingleList: React.FC<Props> = ({ export const SingleList: React.FC<Props> = ({
type,
currentState, currentState,
groupTitle, groupTitle,
groupedByIssues, addIssueToGroup,
selectedGroup, handleIssueAction,
addIssueToState,
makeIssueCopy,
handleEditIssue,
handleDeleteIssue,
openIssuesListModal, openIssuesListModal,
removeIssue, removeIssue,
isCompleted = false, disableUserActions,
user, user,
userAuth, userAuth,
viewProps,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues"); const isArchivedIssues = router.pathname.includes("archived-issues");
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
const { groupByProperty: selectedGroup, groupedIssues } = viewProps;
const { data: issueLabels } = useSWR<IIssueLabels[]>( const { data: issueLabels } = useSWR<IIssueLabels[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
@ -85,6 +79,8 @@ export const SingleList: React.FC<Props> = ({
: null : null
); );
const { projects } = useProjects();
const getGroupTitle = () => { const getGroupTitle = () => {
let title = addSpaceIfCamelCase(groupTitle); let title = addSpaceIfCamelCase(groupTitle);
@ -95,6 +91,9 @@ export const SingleList: React.FC<Props> = ({
case "labels": case "labels":
title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None"; title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None";
break; break;
case "project":
title = projects?.find((p) => p.id === groupTitle)?.name ?? "None";
break;
case "created_by": case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member; const member = members?.find((member) => member.member.id === groupTitle)?.member;
title = title =
@ -115,9 +114,22 @@ export const SingleList: React.FC<Props> = ({
icon = icon =
currentState && getStateGroupIcon(currentState.group, "16", "16", currentState.color); currentState && getStateGroupIcon(currentState.group, "16", "16", currentState.color);
break; break;
case "state_detail.group":
icon = getStateGroupIcon(groupTitle as any, "16", "16");
break;
case "priority": case "priority":
icon = getPriorityIcon(groupTitle, "text-lg"); icon = getPriorityIcon(groupTitle, "text-lg");
break; break;
case "project":
const project = projects?.find((p) => p.id === groupTitle);
icon =
project &&
(project.emoji !== null
? renderEmoji(project.emoji)
: project.icon_prop !== null
? renderEmoji(project.icon_prop)
: null);
break;
case "labels": case "labels":
const labelColor = const labelColor =
issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000"; issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000";
@ -138,6 +150,8 @@ export const SingleList: React.FC<Props> = ({
return icon; return icon;
}; };
if (!groupedIssues) return null;
return ( return (
<Disclosure as="div" defaultOpen> <Disclosure as="div" defaultOpen>
{({ open }) => ( {({ open }) => (
@ -156,7 +170,7 @@ export const SingleList: React.FC<Props> = ({
<h2 className="font-medium leading-5">All Issues</h2> <h2 className="font-medium leading-5">All Issues</h2>
)} )}
<span className="text-custom-text-200 min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs"> <span className="text-custom-text-200 min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs">
{groupedByIssues[groupTitle as keyof IIssue].length} {groupedIssues[groupTitle as keyof IIssue].length}
</span> </span>
</div> </div>
</Disclosure.Button> </Disclosure.Button>
@ -166,11 +180,11 @@ export const SingleList: React.FC<Props> = ({
<button <button
type="button" type="button"
className="p-1 text-custom-text-200 hover:bg-custom-background-80" className="p-1 text-custom-text-200 hover:bg-custom-background-80"
onClick={addIssueToState} onClick={addIssueToGroup}
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
) : isCompleted ? ( ) : disableUserActions ? (
"" ""
) : ( ) : (
<CustomMenu <CustomMenu
@ -182,7 +196,7 @@ export const SingleList: React.FC<Props> = ({
position="right" position="right"
noBorder noBorder
> >
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem> <CustomMenu.MenuItem onClick={addIssueToGroup}>Create new</CustomMenu.MenuItem>
{openIssuesListModal && ( {openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}> <CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue Add an existing issue
@ -201,26 +215,26 @@ export const SingleList: React.FC<Props> = ({
leaveTo="transform opacity-0" leaveTo="transform opacity-0"
> >
<Disclosure.Panel> <Disclosure.Panel>
{groupedByIssues[groupTitle] ? ( {groupedIssues[groupTitle] ? (
groupedByIssues[groupTitle].length > 0 ? ( groupedIssues[groupTitle].length > 0 ? (
groupedByIssues[groupTitle].map((issue, index) => ( groupedIssues[groupTitle].map((issue, index) => (
<SingleListIssue <SingleListIssue
key={issue.id} key={issue.id}
type={type} type={type}
issue={issue} issue={issue}
properties={properties}
groupTitle={groupTitle} groupTitle={groupTitle}
index={index} index={index}
editIssue={() => handleEditIssue(issue)} editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => makeIssueCopy(issue)} makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={handleDeleteIssue} handleDeleteIssue={() => handleIssueAction(issue, "delete")}
removeIssue={() => { removeIssue={() => {
if (removeIssue !== null && issue.bridge_id) if (removeIssue !== null && issue.bridge_id)
removeIssue(issue.bridge_id, issue.id); removeIssue(issue.bridge_id, issue.id);
}} }}
isCompleted={isCompleted} disableUserActions={disableUserActions}
user={user} user={user}
userAuth={userAuth} userAuth={userAuth}
viewProps={viewProps}
/> />
)) ))
) : ( ) : (

View File

@ -10,7 +10,7 @@ import {
ViewAssigneeSelect, ViewAssigneeSelect,
ViewDueDateSelect, ViewDueDateSelect,
ViewEstimateSelect, ViewEstimateSelect,
ViewLabelSelect, ViewIssueLabel,
ViewPrioritySelect, ViewPrioritySelect,
ViewStateSelect, ViewStateSelect,
} from "components/issues"; } from "components/issues";
@ -53,7 +53,7 @@ type Props = {
handleEditIssue: (issue: IIssue) => void; handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void;
gridTemplateColumns: string; gridTemplateColumns: string;
isCompleted?: boolean; disableUserActions: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
nestingLevel: number; nestingLevel: number;
@ -68,7 +68,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
handleEditIssue, handleEditIssue,
handleDeleteIssue, handleDeleteIssue,
gridTemplateColumns, gridTemplateColumns,
isCompleted = false, disableUserActions,
user, user,
userAuth, userAuth,
nestingLevel, nestingLevel,
@ -190,7 +190,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
{issue.project_detail?.identifier}-{issue.sequence_id} {issue.project_detail?.identifier}-{issue.sequence_id}
</span> </span>
)} )}
{!isNotAllowed && !isCompleted && ( {!isNotAllowed && !disableUserActions && (
<div className="absolute top-0 left-2.5 opacity-0 group-hover:opacity-100"> <div className="absolute top-0 left-2.5 opacity-0 group-hover:opacity-100">
<Popover2 <Popover2
isOpen={isOpen} isOpen={isOpen}
@ -263,7 +263,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
)} )}
</div> </div>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}> <Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="truncate text-custom-text-100 cursor-pointer w-full text-[0.825rem]"> <a className="truncate text-custom-text-100 cursor-pointer w-full text-[0.825rem]">
{issue.name} {issue.name}
</a> </a>
@ -311,15 +311,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
)} )}
{properties.labels && ( {properties.labels && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200"> <div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewLabelSelect <ViewIssueLabel issue={issue} maxRender={1} />
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
tooltipPosition={tooltipPosition}
customButton
user={user}
isNotAllowed={isNotAllowed}
/>
</div> </div>
)} )}

View File

@ -8,32 +8,28 @@ import useSubIssue from "hooks/use-sub-issue";
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types";
type Props = { type Props = {
key: string;
issue: IIssue; issue: IIssue;
index: number; index: number;
expandedIssues: string[]; expandedIssues: string[];
setExpandedIssues: React.Dispatch<React.SetStateAction<string[]>>; setExpandedIssues: React.Dispatch<React.SetStateAction<string[]>>;
properties: Properties; properties: Properties;
handleEditIssue: (issue: IIssue) => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDeleteIssue: (issue: IIssue) => void;
gridTemplateColumns: string; gridTemplateColumns: string;
isCompleted?: boolean; disableUserActions: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
nestingLevel?: number; nestingLevel?: number;
}; };
export const SpreadsheetIssues: React.FC<Props> = ({ export const SpreadsheetIssues: React.FC<Props> = ({
key,
index, index,
issue, issue,
expandedIssues, expandedIssues,
setExpandedIssues, setExpandedIssues,
gridTemplateColumns, gridTemplateColumns,
properties, properties,
handleEditIssue, handleIssueAction,
handleDeleteIssue, disableUserActions,
isCompleted = false,
user, user,
userAuth, userAuth,
nestingLevel = 0, nestingLevel = 0,
@ -64,9 +60,9 @@ export const SpreadsheetIssues: React.FC<Props> = ({
handleToggleExpand={handleToggleExpand} handleToggleExpand={handleToggleExpand}
gridTemplateColumns={gridTemplateColumns} gridTemplateColumns={gridTemplateColumns}
properties={properties} properties={properties}
handleEditIssue={handleEditIssue} handleEditIssue={() => handleIssueAction(issue, "edit")}
handleDeleteIssue={handleDeleteIssue} handleDeleteIssue={() => handleIssueAction(issue, "delete")}
isCompleted={isCompleted} disableUserActions={disableUserActions}
user={user} user={user}
userAuth={userAuth} userAuth={userAuth}
nestingLevel={nestingLevel} nestingLevel={nestingLevel}
@ -76,7 +72,7 @@ export const SpreadsheetIssues: React.FC<Props> = ({
!isLoading && !isLoading &&
subIssues && subIssues &&
subIssues.length > 0 && subIssues.length > 0 &&
subIssues.map((subIssue: IIssue, subIndex: number) => ( subIssues.map((subIssue: IIssue) => (
<SpreadsheetIssues <SpreadsheetIssues
key={subIssue.id} key={subIssue.id}
issue={subIssue} issue={subIssue}
@ -85,9 +81,8 @@ export const SpreadsheetIssues: React.FC<Props> = ({
setExpandedIssues={setExpandedIssues} setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns} gridTemplateColumns={gridTemplateColumns}
properties={properties} properties={properties}
handleEditIssue={handleEditIssue} handleIssueAction={handleIssueAction}
handleDeleteIssue={handleDeleteIssue} disableUserActions={disableUserActions}
isCompleted={isCompleted}
user={user} user={user}
userAuth={userAuth} userAuth={userAuth}
nestingLevel={nestingLevel + 1} nestingLevel={nestingLevel + 1}

View File

@ -17,28 +17,26 @@ import { SPREADSHEET_COLUMN } from "constants/spreadsheet";
import { PlusIcon } from "@heroicons/react/24/outline"; import { PlusIcon } from "@heroicons/react/24/outline";
type Props = { type Props = {
type: "issue" | "cycle" | "module"; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null; openIssuesListModal?: (() => void) | null;
isCompleted?: boolean; disableUserActions: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
userAuth: UserAuth; userAuth: UserAuth;
}; };
export const SpreadsheetView: React.FC<Props> = ({ export const SpreadsheetView: React.FC<Props> = ({
type, handleIssueAction,
handleEditIssue,
handleDeleteIssue,
openIssuesListModal, openIssuesListModal,
isCompleted = false, disableUserActions,
user, user,
userAuth, userAuth,
}) => { }) => {
const [expandedIssues, setExpandedIssues] = useState<string[]>([]); const [expandedIssues, setExpandedIssues] = useState<string[]>([]);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
const { spreadsheetIssues } = useSpreadsheetIssuesView(); const { spreadsheetIssues } = useSpreadsheetIssuesView();
@ -76,9 +74,8 @@ export const SpreadsheetView: React.FC<Props> = ({
setExpandedIssues={setExpandedIssues} setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns} gridTemplateColumns={gridTemplateColumns}
properties={properties} properties={properties}
handleEditIssue={handleEditIssue} handleIssueAction={handleIssueAction}
handleDeleteIssue={handleDeleteIssue} disableUserActions={disableUserActions}
isCompleted={isCompleted}
user={user} user={user}
userAuth={userAuth} userAuth={userAuth}
/> />
@ -99,7 +96,7 @@ export const SpreadsheetView: React.FC<Props> = ({
Add Issue Add Issue
</button> </button>
) : ( ) : (
!isCompleted && ( !disableUserActions && (
<CustomMenu <CustomMenu
className="sticky left-0 z-[1]" className="sticky left-0 z-[1]"
customButton={ customButton={

View File

@ -110,8 +110,43 @@ export const ActiveCycleDetails: React.FC = () => {
if (!cycle) if (!cycle)
return ( return (
<div className="flex w-full items-center justify-start rounded-[10px] bg-custom-background-80 px-6 py-4"> <div className="h-full grid place-items-center text-center">
<h3 className="text-base font-medium text-custom-text-100 ">No active cycle is present.</h3> <div className="space-y-2">
<div className="mx-auto flex justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="66"
height="66"
viewBox="0 0 66 66"
fill="none"
>
<circle
cx="34.375"
cy="34.375"
r="22"
stroke="rgb(var(--color-text-400))"
stroke-linecap="round"
/>
<path
d="M36.4375 20.9919C36.4375 19.2528 37.6796 17.8127 39.1709 18.1419C40.125 18.3526 41.0604 18.6735 41.9625 19.1014C43.7141 19.9322 45.3057 21.1499 46.6464 22.685C47.987 24.2202 49.0505 26.0426 49.776 28.0484C50.5016 30.0541 50.875 32.2038 50.875 34.3748C50.875 36.5458 50.5016 38.6956 49.776 40.7013C49.0505 42.7071 47.987 44.5295 46.6464 46.0647C45.3057 47.5998 43.7141 48.8175 41.9625 49.6483C41.0604 50.0762 40.125 50.3971 39.1709 50.6077C37.6796 50.937 36.4375 49.4969 36.4375 47.7578L36.4375 20.9919Z"
fill="rgb(var(--color-text-400))"
/>
</svg>
</div>
<h4 className="text-sm text-custom-text-200">No active cycle</h4>
<button
type="button"
className="text-custom-primary-100 text-sm outline-none"
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "q",
});
document.dispatchEvent(e);
}}
>
Create a new cycle
</button>
</div>
</div> </div>
); );

View File

@ -9,6 +9,7 @@ import cyclesService from "services/cycles.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
import useLocalStorage from "hooks/use-local-storage";
// components // components
import { import {
CreateUpdateCycleModal, CreateUpdateCycleModal,
@ -18,11 +19,7 @@ import {
SingleCycleList, SingleCycleList,
} from "components/cycles"; } from "components/cycles";
// ui // ui
import { EmptyState, Loader } from "components/ui"; import { Loader } from "components/ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// images
import emptyCycle from "public/empty-state/cycle.svg";
// helpers // helpers
import { getDateRangeStatus } from "helpers/date-time.helper"; import { getDateRangeStatus } from "helpers/date-time.helper";
// types // types
@ -48,6 +45,8 @@ export const CyclesView: React.FC<Props> = ({ cycles, viewType }) => {
const [deleteCycleModal, setDeleteCycleModal] = useState(false); const [deleteCycleModal, setDeleteCycleModal] = useState(false);
const [selectedCycleToDelete, setSelectedCycleToDelete] = useState<ICycle | null>(null); const [selectedCycleToDelete, setSelectedCycleToDelete] = useState<ICycle | null>(null);
const { storedValue: cycleTab } = useLocalStorage("cycleTab", "All");
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -206,19 +205,48 @@ export const CyclesView: React.FC<Props> = ({ cycles, viewType }) => {
<CyclesListGanttChartView cycles={cycles ?? []} /> <CyclesListGanttChartView cycles={cycles ?? []} />
) )
) : ( ) : (
<EmptyState <div className="h-full grid place-items-center text-center">
title="Plan your project with cycles" <div className="space-y-2">
description="Cycle is a custom time period in which a team works to complete items on their backlog." <div className="mx-auto flex justify-center">
image={emptyCycle} <svg
buttonText="New Cycle" xmlns="http://www.w3.org/2000/svg"
buttonIcon={<PlusIcon className="h-4 w-4" />} width="66"
onClick={() => { height="66"
const e = new KeyboardEvent("keydown", { viewBox="0 0 66 66"
key: "q", fill="none"
}); >
document.dispatchEvent(e); <circle
}} cx="34.375"
/> cy="34.375"
r="22"
stroke="rgb(var(--color-text-400))"
stroke-linecap="round"
/>
<path
d="M36.4375 20.9919C36.4375 19.2528 37.6796 17.8127 39.1709 18.1419C40.125 18.3526 41.0604 18.6735 41.9625 19.1014C43.7141 19.9322 45.3057 21.1499 46.6464 22.685C47.987 24.2202 49.0505 26.0426 49.776 28.0484C50.5016 30.0541 50.875 32.2038 50.875 34.3748C50.875 36.5458 50.5016 38.6956 49.776 40.7013C49.0505 42.7071 47.987 44.5295 46.6464 46.0647C45.3057 47.5998 43.7141 48.8175 41.9625 49.6483C41.0604 50.0762 40.125 50.3971 39.1709 50.6077C37.6796 50.937 36.4375 49.4969 36.4375 47.7578L36.4375 20.9919Z"
fill="rgb(var(--color-text-400))"
/>
</svg>
</div>
<h4 className="text-sm text-custom-text-200">
{cycleTab === "All"
? "No cycles"
: `No ${cycleTab === "Drafts" ? "draft" : cycleTab?.toLowerCase()} cycles`}
</h4>
<button
type="button"
className="text-custom-primary-100 text-sm outline-none"
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "q",
});
document.dispatchEvent(e);
}}
>
Create a new cycle
</button>
</div>
</div>
) )
) : viewType === "list" ? ( ) : viewType === "list" ? (
<Loader className="space-y-4"> <Loader className="space-y-4">

View File

@ -14,7 +14,7 @@ import { DangerButton, SecondaryButton } from "components/ui";
// icons // icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// types // types
import type { ICurrentUserResponse, ICycle } from "types"; import type { ICurrentUserResponse, ICycle, IProject } from "types";
type TConfirmCycleDeletionProps = { type TConfirmCycleDeletionProps = {
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
@ -27,6 +27,7 @@ import {
CURRENT_CYCLE_LIST, CURRENT_CYCLE_LIST,
CYCLES_LIST, CYCLES_LIST,
DRAFT_CYCLES_LIST, DRAFT_CYCLES_LIST,
PROJECT_DETAILS,
UPCOMING_CYCLES_LIST, UPCOMING_CYCLES_LIST,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
import { getDateRangeStatus } from "helpers/date-time.helper"; import { getDateRangeStatus } from "helpers/date-time.helper";
@ -50,7 +51,7 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
}; };
const handleDeletion = async () => { const handleDeletion = async () => {
if (!data || !workspaceSlug) return; if (!data || !workspaceSlug || !projectId) return;
setIsDeleteLoading(true); setIsDeleteLoading(true);
@ -85,6 +86,21 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
}, },
false false
); );
// update total cycles count in the project details
mutate<IProject>(
PROJECT_DETAILS(projectId.toString()),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
total_cycles: prevData.total_cycles - 1,
};
},
false
);
handleClose(); handleClose();
setToastAlert({ setToastAlert({

View File

@ -29,16 +29,13 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
handleSubmit, handleSubmit,
control, control,
reset, reset,
watch,
} = useForm<ICycle>({ } = useForm<ICycle>({
defaultValues, defaultValues,
}); });
const handleCreateUpdateCycle = async (formData: Partial<ICycle>) => { const handleCreateUpdateCycle = async (formData: Partial<ICycle>) => {
await handleFormSubmit(formData); await handleFormSubmit(formData);
reset({
...defaultValues,
});
}; };
useEffect(() => { useEffect(() => {
@ -48,6 +45,15 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
}); });
}, [data, reset]); }, [data, reset]);
const startDate = watch("start_date");
const endDate = watch("end_date");
const minDate = startDate ? new Date(startDate) : new Date();
minDate.setDate(minDate.getDate() + 1);
const maxDate = endDate ? new Date(endDate) : null;
maxDate?.setDate(maxDate.getDate() - 1);
return ( return (
<form onSubmit={handleSubmit(handleCreateUpdateCycle)}> <form onSubmit={handleSubmit(handleCreateUpdateCycle)}>
<div className="space-y-5"> <div className="space-y-5">
@ -91,7 +97,13 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
control={control} control={control}
name="start_date" name="start_date"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<DateSelect label="Start date" value={value} onChange={(val) => onChange(val)} /> <DateSelect
label="Start date"
value={value}
onChange={(val) => onChange(val)}
minDate={new Date()}
maxDate={maxDate ?? undefined}
/>
)} )}
/> />
</div> </div>
@ -100,7 +112,12 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
control={control} control={control}
name="end_date" name="end_date"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<DateSelect label="End date" value={value} onChange={(val) => onChange(val)} /> <DateSelect
label="End date"
value={value}
onChange={(val) => onChange(val)}
minDate={minDate}
/>
)} )}
/> />
</div> </div>

View File

@ -13,9 +13,9 @@ import useToast from "hooks/use-toast";
// components // components
import { CycleForm } from "components/cycles"; import { CycleForm } from "components/cycles";
// helper // helper
import { getDateRangeStatus, isDateGreaterThanToday } from "helpers/date-time.helper"; import { getDateRangeStatus } from "helpers/date-time.helper";
// types // types
import type { ICurrentUserResponse, ICycle } from "types"; import type { CycleDateCheckData, ICurrentUserResponse, ICycle, IProject } from "types";
// fetch keys // fetch keys
import { import {
COMPLETED_CYCLES_LIST, COMPLETED_CYCLES_LIST,
@ -23,6 +23,7 @@ import {
CYCLES_LIST, CYCLES_LIST,
DRAFT_CYCLES_LIST, DRAFT_CYCLES_LIST,
INCOMPLETE_CYCLES_LIST, INCOMPLETE_CYCLES_LIST,
PROJECT_DETAILS,
UPCOMING_CYCLES_LIST, UPCOMING_CYCLES_LIST,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
@ -65,7 +66,20 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
} }
mutate(INCOMPLETE_CYCLES_LIST(projectId.toString())); mutate(INCOMPLETE_CYCLES_LIST(projectId.toString()));
mutate(CYCLES_LIST(projectId.toString())); mutate(CYCLES_LIST(projectId.toString()));
handleClose();
// update total cycles count in the project details
mutate<IProject>(
PROJECT_DETAILS(projectId.toString()),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
total_cycles: prevData.total_cycles + 1,
};
},
false
);
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -121,8 +135,6 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
} }
} }
handleClose();
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
@ -138,19 +150,16 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
}); });
}; };
const dateChecker = async (payload: any) => { const dateChecker = async (payload: CycleDateCheckData) => {
try { let status = false;
const res = await cycleService.cycleDateCheck(
workspaceSlug as string, await cycleService
projectId as string, .cycleDateCheck(workspaceSlug as string, projectId as string, payload)
payload .then((res) => {
); status = res.status;
console.log(res); });
return res.status;
} catch (err) { return status;
console.log(err);
return false;
}
}; };
const handleFormSubmit = async (formData: Partial<ICycle>) => { const handleFormSubmit = async (formData: Partial<ICycle>) => {
@ -160,66 +169,34 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
...formData, ...formData,
}; };
if (payload.start_date && payload.end_date) { let isDateValid: boolean = true;
if (!isDateGreaterThanToday(payload.end_date)) {
setToastAlert({
type: "error",
title: "Error!",
message: "Unable to create cycle in past date. Please enter a valid date.",
});
handleClose();
return;
}
if (data?.start_date && data?.end_date) { if (payload.start_date && payload.end_date) {
const isDateValidForExistingCycle = await dateChecker({ if (data?.start_date && data?.end_date)
isDateValid = await dateChecker({
start_date: payload.start_date, start_date: payload.start_date,
end_date: payload.end_date, end_date: payload.end_date,
cycle_id: data.id, cycle_id: data.id,
}); });
else
if (isDateValidForExistingCycle) { isDateValid = await dateChecker({
await updateCycle(data.id, payload); start_date: payload.start_date,
return; end_date: payload.end_date,
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
handleClose();
return;
}
}
const isDateValid = await dateChecker({
start_date: payload.start_date,
end_date: payload.end_date,
});
if (isDateValid) {
if (data) {
await updateCycle(data.id, payload);
} else {
await createCycle(payload);
}
} else {
setToastAlert({
type: "error",
title: "Error!",
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
}); });
handleClose();
}
} else {
if (data) {
await updateCycle(data.id, payload);
} else {
await createCycle(payload);
}
} }
if (isDateValid) {
if (data) await updateCycle(data.id, payload);
else await createCycle(payload);
handleClose();
} else
setToastAlert({
type: "error",
title: "Error!",
message:
"You already have a cycle on the given dates, if you want to create a draft cycle, remove the dates.",
});
}; };
return ( return (

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from "react"; import React, { useEffect, useState } from "react";
// headless ui // headless ui
import { Tab, Transition, Popover } from "@headlessui/react"; import { Tab, Transition, Popover } from "@headlessui/react";
// react colors // react colors
@ -11,8 +11,6 @@ import icons from "./icons.json";
// helpers // helpers
import { getRecentEmojis, saveRecentEmoji } from "./helpers"; import { getRecentEmojis, saveRecentEmoji } from "./helpers";
import { getRandomEmoji, renderEmoji } from "helpers/emoji.helper"; import { getRandomEmoji, renderEmoji } from "helpers/emoji.helper";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
const tabOptions = [ const tabOptions = [
{ {
@ -26,8 +24,6 @@ const tabOptions = [
]; ];
const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange, onIconColorChange }) => { const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange, onIconColorChange }) => {
const ref = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [openColorPicker, setOpenColorPicker] = useState(false); const [openColorPicker, setOpenColorPicker] = useState(false);
const [activeColor, setActiveColor] = useState<string>("rgb(var(--color-text-200))"); const [activeColor, setActiveColor] = useState<string>("rgb(var(--color-text-200))");
@ -38,20 +34,13 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange, onIconColorC
setRecentEmojis(getRecentEmojis()); setRecentEmojis(getRecentEmojis());
}, []); }, []);
useOutsideClickDetector(ref, () => {
setIsOpen(false);
});
useEffect(() => { useEffect(() => {
if (!value || value?.length === 0) onChange(getRandomEmoji()); if (!value || value?.length === 0) onChange(getRandomEmoji());
}, [value, onChange]); }, [value, onChange]);
return ( return (
<Popover className="relative z-[1]" ref={ref}> <Popover className="relative z-[1]">
<Popover.Button <Popover.Button onClick={() => setIsOpen((prev) => !prev)} className="outline-none">
className="rounded-full bg-custom-background-90 p-2 outline-none sm:text-sm"
onClick={() => setIsOpen((prev) => !prev)}
>
{label} {label}
</Popover.Button> </Popover.Button>
<Transition <Transition

View File

@ -1,5 +1,5 @@
export type Props = { export type Props = {
label: string | React.ReactNode; label: React.ReactNode;
value: any; value: any;
onChange: ( onChange: (
data: data:

View File

@ -5,6 +5,8 @@ import {
StartedStateIcon, StartedStateIcon,
UnstartedStateIcon, UnstartedStateIcon,
} from "components/icons"; } from "components/icons";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
export const getStateGroupIcon = ( export const getStateGroupIcon = (
stateGroup: "backlog" | "unstarted" | "started" | "completed" | "cancelled", stateGroup: "backlog" | "unstarted" | "started" | "completed" | "cancelled",
@ -14,15 +16,45 @@ export const getStateGroupIcon = (
) => { ) => {
switch (stateGroup) { switch (stateGroup) {
case "backlog": case "backlog":
return <BacklogStateIcon width={width} height={height} color={color} />; return (
<BacklogStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["backlog"]}
/>
);
case "unstarted": case "unstarted":
return <UnstartedStateIcon width={width} height={height} color={color} />; return (
<UnstartedStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["unstarted"]}
/>
);
case "started": case "started":
return <StartedStateIcon width={width} height={height} color={color} />; return (
<StartedStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["started"]}
/>
);
case "completed": case "completed":
return <CompletedStateIcon width={width} height={height} color={color} />; return (
<CompletedStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["completed"]}
/>
);
case "cancelled": case "cancelled":
return <CancelledStateIcon width={width} height={height} color={color} />; return (
<CancelledStateIcon
width={width}
height={height}
color={color ?? STATE_GROUP_COLORS["cancelled"]}
/>
);
default: default:
return <></>; return <></>;
} }

View File

@ -37,37 +37,35 @@ export const FiltersDropdown: React.FC = () => {
id: "priority", id: "priority",
label: "Priority", label: "Priority",
value: PRIORITIES, value: PRIORITIES,
children: [ hasChildren: true,
...PRIORITIES.map((priority) => ({ children: PRIORITIES.map((priority) => ({
id: priority === null ? "null" : priority, id: priority === null ? "null" : priority,
label: ( label: (
<div className="flex items-center gap-2 capitalize"> <div className="flex items-center gap-2 capitalize">
{getPriorityIcon(priority)} {priority ?? "None"} {getPriorityIcon(priority)} {priority ?? "None"}
</div> </div>
), ),
value: { value: {
key: "priority", key: "priority",
value: priority === null ? "null" : priority, value: priority === null ? "null" : priority,
}, },
selected: filters?.priority?.includes(priority === null ? "null" : priority), selected: filters?.priority?.includes(priority === null ? "null" : priority),
})), })),
],
}, },
{ {
id: "inbox_status", id: "inbox_status",
label: "Status", label: "Status",
value: INBOX_STATUS.map((status) => status.value), value: INBOX_STATUS.map((status) => status.value),
children: [ hasChildren: true,
...INBOX_STATUS.map((status) => ({ children: INBOX_STATUS.map((status) => ({
id: status.key, id: status.key,
label: status.label, label: status.label,
value: { value: {
key: "inbox_status", key: "inbox_status",
value: status.value, value: status.value,
}, },
selected: filters?.inbox_status?.includes(status.value), selected: filters?.inbox_status?.includes(status.value),
})), })),
],
}, },
]} ]}
/> />

View File

@ -19,6 +19,7 @@ import {
IssueActivitySection, IssueActivitySection,
IssueDescriptionForm, IssueDescriptionForm,
IssueDetailsSidebar, IssueDetailsSidebar,
IssueReaction,
} from "components/issues"; } from "components/issues";
// ui // ui
import { Loader } from "components/ui"; import { Loader } from "components/ui";
@ -303,6 +304,13 @@ export const InboxMainContent: React.FC = () => {
} }
/> />
</div> </div>
<IssueReaction
projectId={projectId}
workspaceSlug={workspaceSlug}
issueId={issueDetails.id}
/>
<div className="space-y-5"> <div className="space-y-5">
<h3 className="text-lg text-custom-text-100">Comments/Activity</h3> <h3 className="text-lg text-custom-text-100">Comments/Activity</h3>
<IssueActivitySection issueId={issueDetails.id} user={user} /> <IssueActivitySection issueId={issueDetails.id} user={user} />

View File

@ -30,7 +30,7 @@ export const JiraImportUsers: FC = () => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { workspaceMembers: members } = useWorkspaceMembers(workspaceSlug?.toString()); const { workspaceMembers: members } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
const options = members?.map((member) => ({ const options = members?.map((member) => ({
value: member.member.email, value: member.member.email,

View File

@ -1,106 +1,23 @@
import React from "react"; import React from "react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
// hooks
import useEstimateOption from "hooks/use-estimate-option";
// components // components
import { ActivityIcon, ActivityMessage } from "components/core";
import { CommentCard } from "components/issues/comment"; import { CommentCard } from "components/issues/comment";
// ui // ui
import { Icon, Loader } from "components/ui"; import { Icon, Loader } from "components/ui";
// icons
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon } from "components/icons";
// helpers // helpers
import { renderShortDateWithYearFormat, timeAgo } from "helpers/date-time.helper"; import { timeAgo } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types // types
import { ICurrentUserResponse, IIssueComment, IIssueLabels } from "types"; import { ICurrentUserResponse, IIssueComment } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUES_ACTIVITY, PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
const activityDetails: {
[key: string]: {
message?: string;
icon: JSX.Element;
};
} = {
assignee: {
message: "removed the assignee",
icon: <Icon iconName="group" className="!text-sm" aria-hidden="true" />,
},
assignees: {
message: "added a new assignee",
icon: <Icon iconName="group" className="!text-sm" aria-hidden="true" />,
},
blocks: {
message: "marked this issue being blocked by",
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
},
blocking: {
message: "marked this issue is blocking",
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
},
cycles: {
message: "set the cycle to",
icon: <Icon iconName="contrast" className="!text-sm" aria-hidden="true" />,
},
estimate_point: {
message: "set the estimate point to",
icon: <Icon iconName="change_history" className="!text-sm" aria-hidden="true" />,
},
labels: {
icon: <Icon iconName="sell" className="!text-sm" aria-hidden="true" />,
},
modules: {
message: "set the module to",
icon: <Icon iconName="dataset" className="!text-sm" aria-hidden="true" />,
},
state: {
message: "set the state to",
icon: <Squares2X2Icon className="h-3 w-3" aria-hidden="true" />,
},
priority: {
message: "set the priority to",
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />,
},
name: {
message: "set the name to",
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
},
description: {
message: "updated the description.",
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
},
target_date: {
message: "set the due date to",
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
},
parent: {
message: "set the parent to",
icon: <Icon iconName="supervised_user_circle" className="!text-sm" aria-hidden="true" />,
},
estimate: {
message: "updated the estimate",
icon: <Icon iconName="change_history" className="!text-sm" aria-hidden="true" />,
},
link: {
message: "updated the link",
icon: <Icon iconName="link" className="!text-sm" aria-hidden="true" />,
},
attachment: {
message: "updated the attachment",
icon: <Icon iconName="attach_file" className="!text-sm" aria-hidden="true" />,
},
archived_at: {
message: "archived",
icon: <Icon iconName="archive" className="!text-sm" aria-hidden="true" />,
},
};
type Props = { type Props = {
issueId: string; issueId: string;
@ -111,29 +28,17 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { isEstimateActive, estimatePoints } = useEstimateOption();
const { data: issueActivities, mutate: mutateIssueActivities } = useSWR( const { data: issueActivities, mutate: mutateIssueActivities } = useSWR(
workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY(issueId as string) : null, workspaceSlug && projectId ? PROJECT_ISSUES_ACTIVITY(issueId) : null,
workspaceSlug && projectId && issueId
? () =>
issuesService.getIssueActivities(
workspaceSlug as string,
projectId as string,
issueId as string
)
: null
);
const { data: issueLabels } = useSWR<IIssueLabels[]>(
projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string) ? () =>
issuesService.getIssueActivities(workspaceSlug as string, projectId as string, issueId)
: null : null
); );
const handleCommentUpdate = async (comment: IIssueComment) => { const handleCommentUpdate = async (comment: IIssueComment) => {
if (!workspaceSlug || !projectId || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
await issuesService await issuesService
.patchIssueComment( .patchIssueComment(
workspaceSlug as string, workspaceSlug as string,
@ -143,9 +48,7 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
comment, comment,
user user
) )
.then((res) => { .then((res) => mutateIssueActivities());
mutateIssueActivities();
});
}; };
const handleCommentDelete = async (commentId: string) => { const handleCommentDelete = async (commentId: string) => {
@ -164,15 +67,6 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
.then(() => mutateIssueActivities()); .then(() => mutateIssueActivities());
}; };
const getLabelColor = (labelId: string) => {
if (!issueLabels) return;
const label = issueLabels.find((label) => label.id === labelId);
if (typeof label !== "undefined") {
return label.color !== "" ? label.color : "#000000";
}
return "#000000";
};
if (!issueActivities) { if (!issueActivities) {
return ( return (
<Loader className="space-y-4"> <Loader className="space-y-4">
@ -195,207 +89,78 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
return ( return (
<div className="flow-root"> <div className="flow-root">
<ul role="list" className="-mb-4"> <ul role="list" className="-mb-4">
{issueActivities.map((activityItem, activityItemIdx) => { {issueActivities.map((activityItem, index) => {
// determines what type of action is performed // determines what type of action is performed
let action = activityDetails[activityItem.field as keyof typeof activityDetails]?.message; const message = activityItem.field ? (
if (activityItem.field === "labels") { <ActivityMessage activity={activityItem} />
action = activityItem.new_value !== "" ? "added a new label" : "removed the label"; ) : (
} else if (activityItem.field === "blocking") { "created the issue."
action = );
activityItem.new_value !== ""
? "marked this issue is blocking"
: "removed the issue from blocking";
} else if (activityItem.field === "blocks") {
action =
activityItem.new_value !== ""
? "marked this issue being blocked by"
: "removed blocker";
} else if (activityItem.field === "target_date") {
action =
activityItem.new_value && activityItem.new_value !== ""
? "set the due date to"
: "removed the due date";
} else if (activityItem.field === "parent") {
action =
activityItem.new_value && activityItem.new_value !== ""
? "set the parent to"
: "removed the parent";
} else if (activityItem.field === "priority") {
action =
activityItem.new_value && activityItem.new_value !== ""
? "set the priority to"
: "removed the priority";
} else if (activityItem.field === "description") {
action = "updated the";
} else if (activityItem.field === "attachment") {
action = `${activityItem.verb} the`;
} else if (activityItem.field === "link") {
action = `${activityItem.verb} the`;
} else if (activityItem.field === "estimate") {
action = "updated the";
} else if (activityItem.field === "cycles") {
action =
activityItem.new_value && activityItem.new_value !== ""
? "set the cycle to"
: "removed the cycle";
} else if (activityItem.field === "modules") {
action =
activityItem.new_value && activityItem.new_value !== ""
? "set the module to"
: "removed the module";
} else if (activityItem.field === "archived_at") {
action =
activityItem.new_value && activityItem.new_value === "restore"
? "restored the issue"
: "archived the issue";
}
// for values that are after the action clause
let value: any = activityItem.new_value ? activityItem.new_value : activityItem.old_value;
if (
activityItem.verb === "created" &&
activityItem.field !== "cycles" &&
activityItem.field !== "modules" &&
activityItem.field !== "attachment" &&
activityItem.field !== "link" &&
activityItem.field !== "estimate"
) {
value = <span className="text-custom-text-200">created this issue.</span>;
} else if (activityItem.field === "state") {
value = activityItem.new_value ? addSpaceIfCamelCase(activityItem.new_value) : "None";
} else if (activityItem.field === "labels") {
let name;
let id = "#000000";
if (activityItem.new_value !== "") {
name = activityItem.new_value;
id = activityItem.new_identifier ? activityItem.new_identifier : id;
} else {
name = activityItem.old_value;
id = activityItem.old_identifier ? activityItem.old_identifier : id;
}
value = (
<span className="relative inline-flex items-center rounded-full border border-custom-border-200 px-2 py-0.5 text-xs">
<span className="absolute flex flex-shrink-0 items-center justify-center">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: getLabelColor(id),
}}
aria-hidden="true"
/>
</span>
<span className="ml-3 font-medium text-custom-text-100">{name}</span>
</span>
);
} else if (activityItem.field === "assignees") {
value = activityItem.new_value;
} else if (activityItem.field === "target_date") {
const date =
activityItem.new_value && activityItem.new_value !== ""
? activityItem.new_value
: activityItem.old_value;
value = renderShortDateWithYearFormat(date as string);
} else if (activityItem.field === "description") {
value = "description";
} else if (activityItem.field === "attachment") {
value = "attachment";
} else if (activityItem.field === "cycles") {
const cycles =
activityItem.new_value && activityItem.new_value !== ""
? activityItem.new_value
: activityItem.old_value;
value = cycles ? addSpaceIfCamelCase(cycles) : "None";
} else if (activityItem.field === "modules") {
const modules =
activityItem.new_value && activityItem.new_value !== ""
? activityItem.new_value
: activityItem.old_value;
value = modules ? addSpaceIfCamelCase(modules) : "None";
} else if (activityItem.field === "link") {
value = "link";
} else if (activityItem.field === "estimate_point") {
value = activityItem.new_value
? isEstimateActive
? estimatePoints.find((e) => e.key === parseInt(activityItem.new_value ?? "", 10))
?.value
: activityItem.new_value +
` Point${parseInt(activityItem.new_value ?? "", 10) > 1 ? "s" : ""}`
: "None";
}
if ("field" in activityItem && activityItem.field !== "updated_by") { if ("field" in activityItem && activityItem.field !== "updated_by") {
return ( return (
<li key={activityItem.id}> <li key={activityItem.id}>
<div className="relative pb-1"> <div className="relative pb-1">
{issueActivities.length > 1 && activityItemIdx !== issueActivities.length - 1 ? ( {issueActivities.length > 1 && index !== issueActivities.length - 1 ? (
<span <span
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80" className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80"
aria-hidden="true" aria-hidden="true"
/> />
) : null} ) : null}
<div className="relative flex items-start space-x-2"> <div className="relative flex items-start space-x-2">
<> <div>
<div> <div className="relative px-1.5">
<div className="relative px-1.5"> <div className="mt-1.5">
<div className="mt-1.5"> <div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white"> {activityItem.field ? (
{activityItem.field ? ( activityItem.new_value === "restore" ? (
activityItem.new_value === "restore" ? ( <Icon iconName="history" className="text-sm text-custom-text-200" />
<Icon
iconName="history"
className="text-sm text-custom-text-200"
/>
) : (
activityDetails[
activityItem.field as keyof typeof activityDetails
]?.icon
)
) : activityItem.actor_detail.avatar &&
activityItem.actor_detail.avatar !== "" ? (
<img
src={activityItem.actor_detail.avatar}
alt={activityItem.actor_detail.first_name}
height={24}
width={24}
className="rounded-full"
/>
) : ( ) : (
<div <ActivityIcon activity={activityItem} />
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`} )
> ) : activityItem.actor_detail.avatar &&
{activityItem.actor_detail.first_name.charAt(0)} activityItem.actor_detail.avatar !== "" ? (
</div> <img
)} src={activityItem.actor_detail.avatar}
</div> alt={activityItem.actor_detail.first_name}
height={24}
width={24}
className="rounded-full"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
>
{activityItem.actor_detail.first_name.charAt(0)}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
<div className="min-w-0 flex-1 py-3"> </div>
<div className="text-xs text-custom-text-200"> <div className="min-w-0 flex-1 py-3">
{activityItem.field === "archived_at" && <div className="text-xs text-custom-text-200 break-words">
activityItem.new_value !== "restore" ? ( {activityItem.field === "archived_at" &&
<span className="text-gray font-medium">Plane</span> activityItem.new_value !== "restore" ? (
) : ( <span className="text-gray font-medium">Plane</span>
<span className="text-gray font-medium"> ) : activityItem.actor_detail.is_bot ? (
{activityItem.actor_detail.first_name} <span className="text-gray font-medium">
{activityItem.actor_detail.is_bot {activityItem.actor_detail.first_name} Bot
? " Bot"
: " " + activityItem.actor_detail.last_name}
</span>
)}
<span> {action} </span>
{activityItem.field !== "archived_at" && (
<span className="text-xs font-medium text-custom-text-100">
{" "}
{value}{" "}
</span>
)}
<span className="whitespace-nowrap">
{timeAgo(activityItem.created_at)}
</span> </span>
</div> ) : (
<Link href={`/${workspaceSlug}/profile/${activityItem.actor_detail.id}`}>
<a className="text-gray font-medium">
{activityItem.actor_detail.first_name}{" "}
{activityItem.actor_detail.last_name}
</a>
</Link>
)}{" "}
{message}{" "}
<span className="whitespace-nowrap">
{timeAgo(activityItem.created_at)}
</span>
</div> </div>
</> </div>
</div> </div>
</div> </div>
</li> </li>
@ -404,7 +169,7 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
return ( return (
<div key={activityItem.id} className="mt-4"> <div key={activityItem.id} className="mt-4">
<CommentCard <CommentCard
comment={activityItem as any} comment={activityItem as IIssueComment}
onSubmit={handleCommentUpdate} onSubmit={handleCommentUpdate}
handleCommentDeletion={handleCommentDelete} handleCommentDeletion={handleCommentDelete}
/> />

View File

@ -6,14 +6,14 @@ import { mutate } from "swr";
// react-dropzone // react-dropzone
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
// toast
import useToast from "hooks/use-toast";
// fetch key
import { ISSUE_ATTACHMENTS } from "constants/fetch-keys";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
// type // hooks
import useToast from "hooks/use-toast";
// types
import { IIssueAttachment } from "types"; import { IIssueAttachment } from "types";
// fetch-keys
import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
const maxFileSize = 5 * 1024 * 1024; // 5 MB const maxFileSize = 5 * 1024 * 1024; // 5 MB
@ -56,6 +56,7 @@ export const IssueAttachmentUpload: React.FC<Props> = ({ disabled = false }) =>
(prevData) => [res, ...(prevData ?? [])], (prevData) => [res, ...(prevData ?? [])],
false false
); );
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",

View File

@ -81,12 +81,12 @@ export const IssueAttachments = () => {
} uploaded on ${renderLongDateFormat(file.updated_at)}`} } uploaded on ${renderLongDateFormat(file.updated_at)}`}
> >
<span> <span>
<ExclamationIcon className="h-3 w-3 fill-current text-custom-text-100" /> <ExclamationIcon className="h-3 w-3 fill-current" />
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex items-center gap-3 text-xs text-gray-500"> <div className="flex items-center gap-3 text-xs text-custom-text-200">
<span>{getFileExtension(file.asset).toUpperCase()}</span> <span>{getFileExtension(file.asset).toUpperCase()}</span>
<span>{convertBytesToSize(file.attributes.size)}</span> <span>{convertBytesToSize(file.attributes.size)}</span>
</div> </div>
@ -101,7 +101,7 @@ export const IssueAttachments = () => {
setAttachmentDeleteModal(true); setAttachmentDeleteModal(true);
}} }}
> >
<XMarkIcon className="h-4 w-4 text-gray-500 hover:text-gray-800" /> <XMarkIcon className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" />
</button> </button>
</div> </div>
))} ))}

View File

@ -19,7 +19,7 @@ import { getFileName } from "helpers/attachment.helper";
// types // types
import type { IIssueAttachment } from "types"; import type { IIssueAttachment } from "types";
// fetch-keys // fetch-keys
import { ISSUE_ATTACHMENTS } from "constants/fetch-keys"; import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -53,6 +53,7 @@ export const DeleteAttachmentModal: React.FC<Props> = ({ isOpen, setIsOpen, data
issueId as string, issueId as string,
assetId as string assetId as string
) )
.then(() => mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)))
.catch(() => { .catch(() => {
setToastAlert({ setToastAlert({
type: "error", type: "error",

View File

@ -0,0 +1,3 @@
export * from "./attachment-upload";
export * from "./attachments";
export * from "./delete-attachment-modal";

View File

@ -10,6 +10,7 @@ import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon } from "@heroicons/rea
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
// ui // ui
import { CustomMenu } from "components/ui"; import { CustomMenu } from "components/ui";
import { CommentReaction } from "components/issues";
// helpers // helpers
import { timeAgo } from "helpers/date-time.helper"; import { timeAgo } from "helpers/date-time.helper";
// types // types
@ -52,8 +53,8 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
const onEnter = (formData: IIssueComment) => { const onEnter = (formData: IIssueComment) => {
if (isSubmitting) return; if (isSubmitting) return;
setIsEditing(false); setIsEditing(false);
onSubmit(formData); onSubmit(formData);
console.log(formData);
editorRef.current?.setEditorValue(formData.comment_json); editorRef.current?.setEditorValue(formData.comment_json);
showEditorRef.current?.setEditorValue(formData.comment_json); showEditorRef.current?.setEditorValue(formData.comment_json);
@ -138,6 +139,8 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
customClassName="text-xs border border-custom-border-200 bg-custom-background-100" customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
ref={showEditorRef} ref={showEditorRef}
/> />
<CommentReaction projectId={comment.project} commentId={comment.id} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,92 @@
import React from "react";
import { useRouter } from "next/router";
// hooks
import useUser from "hooks/use-user";
import useCommentReaction from "hooks/use-comment-reaction";
// ui
import { ReactionSelector } from "components/core";
// helper
import { renderEmoji } from "helpers/emoji.helper";
type Props = {
projectId?: string | string[];
commentId: string;
};
export const CommentReaction: React.FC<Props> = (props) => {
const { projectId, commentId } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { user } = useUser();
const {
commentReactions,
groupedReactions,
handleReactionCreate,
handleReactionDelete,
isLoading,
} = useCommentReaction(workspaceSlug, projectId, commentId);
const handleReactionClick = (reaction: string) => {
if (!workspaceSlug || !projectId || !commentId) return;
const isSelected = commentReactions?.some(
(r) => r.actor === user?.id && r.reaction === reaction
);
if (isSelected) {
handleReactionDelete(reaction);
} else {
handleReactionCreate(reaction);
}
};
return (
<div className="flex gap-1.5 items-center mt-2">
<ReactionSelector
size="md"
position="top"
value={
commentReactions
?.filter((reaction) => reaction.actor === user?.id)
.map((r) => r.reaction) || []
}
onSelect={handleReactionClick}
/>
{Object.keys(groupedReactions || {}).map(
(reaction) =>
groupedReactions?.[reaction]?.length &&
groupedReactions[reaction].length > 0 && (
<button
type="button"
onClick={() => {
handleReactionClick(reaction);
}}
key={reaction}
className={`flex items-center gap-1 text-custom-text-100 text-sm h-full px-2 py-1 rounded-md ${
commentReactions?.some((r) => r.actor === user?.id && r.reaction === reaction)
? "bg-custom-primary-100/10"
: "bg-custom-background-80"
}`}
>
<span>{renderEmoji(reaction)}</span>
<span
className={
commentReactions?.some((r) => r.actor === user?.id && r.reaction === reaction)
? "text-custom-primary-100"
: ""
}
>
{groupedReactions?.[reaction].length}{" "}
</span>
</button>
)
)}
</div>
);
};

View File

@ -1,2 +1,3 @@
export * from "./add-comment"; export * from "./add-comment";
export * from "./comment-card"; export * from "./comment-card";
export * from "./comment-reaction";

View File

@ -59,11 +59,12 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
}; };
const handleDeletion = async () => { const handleDeletion = async () => {
if (!workspaceSlug || !data) return;
setIsDeleteLoading(true); setIsDeleteLoading(true);
if (!workspaceSlug || !projectId || !data) return;
await issueServices await issueServices
.deleteIssue(workspaceSlug as string, projectId as string, data.id, user) .deleteIssue(workspaceSlug as string, data.project, data.id, user)
.then(() => { .then(() => {
if (issueView === "calendar") { if (issueView === "calendar") {
const calendarFetchKey = cycleId const calendarFetchKey = cycleId
@ -72,7 +73,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams) ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams)
: viewId : viewId
? VIEW_ISSUES(viewId.toString(), calendarParams) ? VIEW_ISSUES(viewId.toString(), calendarParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), calendarParams); : PROJECT_ISSUES_LIST_WITH_PARAMS(data.project, calendarParams);
mutate<IIssue[]>( mutate<IIssue[]>(
calendarFetchKey, calendarFetchKey,
@ -86,7 +87,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams) ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams)
: viewId : viewId
? VIEW_ISSUES(viewId.toString(), spreadsheetParams) ? VIEW_ISSUES(viewId.toString(), spreadsheetParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", spreadsheetParams); : PROJECT_ISSUES_LIST_WITH_PARAMS(data.project, spreadsheetParams);
if (data.parent) { if (data.parent) {
mutate<ISubIssueResponse>( mutate<ISubIssueResponse>(
SUB_ISSUES(data.parent.toString()), SUB_ISSUES(data.parent.toString()),
@ -112,7 +113,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
} else { } else {
if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)); if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
else if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); else if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params)); else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(data.project, params));
} }
handleClose(); handleClose();

View File

@ -96,13 +96,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
setCharacterLimit(false); setCharacterLimit(false);
setIsSubmitting(true); setIsSubmitting(true);
handleSubmit(handleDescriptionFormSubmit)() handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting(false));
.then(() => {
setIsSubmitting(false);
})
.catch(() => {
setIsSubmitting(false);
});
}} }}
required={true} required={true}
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary" className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary"
@ -110,7 +104,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
disabled={!isAllowed} disabled={!isAllowed}
/> />
{characterLimit && ( {characterLimit && (
<div className="pointer-events-none absolute bottom-0 right-0 z-[2] rounded bg-custom-background-80 p-1 text-xs"> <div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 text-custom-text-200 p-0.5 text-xs">
<span <span
className={`${ className={`${
watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : "" watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
@ -123,52 +117,47 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
)} )}
</div> </div>
<span>{errors.name ? errors.name.message : null}</span> <span>{errors.name ? errors.name.message : null}</span>
<Controller <div className="relative">
name="description" <Controller
control={control} name="description"
render={({ field: { value } }) => { control={control}
if (!value && !watch("description_html")) return <></>; render={({ field: { value } }) => {
if (!value && !watch("description_html")) return <></>;
return ( return (
<RemirrorRichTextEditor <RemirrorRichTextEditor
value={ value={
!value || !value ||
value === "" || value === "" ||
(typeof value === "object" && Object.keys(value).length === 0) (typeof value === "object" && Object.keys(value).length === 0)
? watch("description_html") ? watch("description_html")
: value : value
} }
onJSONChange={(jsonValue) => { onJSONChange={(jsonValue) => {
setShowAlert(true); setShowAlert(true);
setValue("description", jsonValue); setValue("description", jsonValue);
}} }}
onHTMLChange={(htmlValue) => { onHTMLChange={(htmlValue) => {
setShowAlert(true); setShowAlert(true);
setValue("description_html", htmlValue); setValue("description_html", htmlValue);
}} }}
onBlur={() => { onBlur={() => {
setIsSubmitting(true); setIsSubmitting(true);
handleSubmit(handleDescriptionFormSubmit)() handleSubmit(handleDescriptionFormSubmit)()
.then(() => { .then(() => setShowAlert(false))
setIsSubmitting(false); .finally(() => setIsSubmitting(false));
setShowAlert(false); }}
}) placeholder="Description"
.catch(() => { editable={isAllowed}
setIsSubmitting(false); />
}); );
}} }}
placeholder="Description" />
editable={isAllowed} {isSubmitting && (
/> <div className="absolute bottom-1 right-1 text-xs text-custom-text-200 bg-custom-background-100 p-3 z-10">
); Saving...
}} </div>
/> )}
<div
className={`absolute -bottom-8 right-0 text-sm text-custom-text-200 ${
isSubmitting ? "block" : "hidden"
}`}
>
Saving...
</div> </div>
</div> </div>
); );

View File

@ -75,6 +75,7 @@ const defaultValues: Partial<IIssue> = {
assignees_list: [], assignees_list: [],
labels: [], labels: [],
labels_list: [], labels_list: [],
target_date: null,
}; };
export interface IssueFormProps { export interface IssueFormProps {
@ -201,18 +202,21 @@ export const IssueForm: FC<IssueFormProps> = ({
else handleAiAssistance(res.response_html); else handleAiAssistance(res.response_html);
}) })
.catch((err) => { .catch((err) => {
const error = err?.data?.error;
if (err.status === 429) if (err.status === 429)
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: message:
error ||
"You have reached the maximum number of requests of 50 requests per month per user.", "You have reached the maximum number of requests of 50 requests per month per user.",
}); });
else else
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: "Some error occurred. Please try again.", message: error || "Some error occurred. Please try again.",
}); });
}) })
.finally(() => setIAmFeelingLucky(false)); .finally(() => setIAmFeelingLucky(false));
@ -224,9 +228,16 @@ export const IssueForm: FC<IssueFormProps> = ({
reset({ reset({
...defaultValues, ...defaultValues,
...initialData, ...initialData,
});
}, [setFocus, initialData, reset]);
// update projectId in form when projectId changes
useEffect(() => {
reset({
...getValues(),
project: projectId, project: projectId,
}); });
}, [setFocus, initialData, reset, projectId]); }, [getValues, projectId, reset]);
return ( return (
<> <>
@ -260,8 +271,10 @@ export const IssueForm: FC<IssueFormProps> = ({
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<IssueProjectSelect <IssueProjectSelect
value={value} value={value}
onChange={onChange} onChange={(val: string) => {
setActiveProject={setActiveProject} onChange(val);
setActiveProject(val);
}}
/> />
)} )}
/> />
@ -271,7 +284,6 @@ export const IssueForm: FC<IssueFormProps> = ({
</h3> </h3>
</div> </div>
{watch("parent") && {watch("parent") &&
watch("parent") !== "" &&
(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (fieldsToShow.includes("all") || fieldsToShow.includes("parent")) &&
selectedParentIssue && ( selectedParentIssue && (
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-80 p-2 text-xs"> <div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-80 p-2 text-xs">
@ -476,7 +488,7 @@ export const IssueForm: FC<IssueFormProps> = ({
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
<CustomMenu ellipsis> <CustomMenu ellipsis>
{watch("parent") && watch("parent") !== "" ? ( {watch("parent") ? (
<> <>
<CustomMenu.MenuItem <CustomMenu.MenuItem
renderAs="button" renderAs="button"

Some files were not shown because too many files have changed in this diff Show More