forked from github/plane
Merge pull request #1729 from makeplane/develop
promote: develop to stage-release
This commit is contained in:
commit
d9339b8f8e
22
README.md
22
README.md
@ -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%"
|
||||||
/>
|
/>
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
@ -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 (
|
||||||
|
@ -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)
|
||||||
|
@ -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,16 +274,17 @@ 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)
|
||||||
|
|
||||||
@ -335,19 +365,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 +502,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 +604,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 +616,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 +642,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
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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",
|
||||||
),
|
),
|
||||||
|
@ -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 (
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -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(
|
||||||
@ -396,6 +483,7 @@ class IssueActivityEndpoint(BaseAPIView):
|
|||||||
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,
|
||||||
|
)
|
||||||
|
@ -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)
|
||||||
|
@ -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:
|
||||||
@ -234,7 +267,7 @@ class ProjectViewSet(BaseViewSet):
|
|||||||
status=status.HTTP_410_GONE,
|
status=status.HTTP_410_GONE,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
capture_exception(e)
|
pr(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,
|
||||||
@ -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()
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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")
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
@ -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,
|
||||||
|
97
apiserver/plane/db/migrations/0039_auto_20230723_2203.py
Normal file
97
apiserver/plane/db/migrations/0039_auto_20230723_2203.py
Normal 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),
|
||||||
|
]
|
@ -0,0 +1,71 @@
|
|||||||
|
# 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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -34,6 +34,8 @@ from .issue import (
|
|||||||
IssueSequence,
|
IssueSequence,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
IssueSubscriber,
|
IssueSubscriber,
|
||||||
|
IssueReaction,
|
||||||
|
CommentReaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .asset import FileAsset
|
from .asset import FileAsset
|
||||||
|
@ -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):
|
||||||
|
@ -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"]
|
||||||
|
@ -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")
|
||||||
|
@ -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"]
|
||||||
|
@ -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():
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
776
apps/app/components/command-palette/command-k.tsx
Normal file
776
apps/app/components/command-palette/command-k.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
95
apps/app/components/command-palette/helpers.tsx
Normal file
95
apps/app/components/command-palette/helpers.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
@ -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";
|
||||||
|
@ -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 }) => ({
|
@ -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>) => {
|
3
apps/app/components/command-palette/issue/index.ts
Normal file
3
apps/app/components/command-palette/issue/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./change-issue-state";
|
||||||
|
export * from "./change-issue-priority";
|
||||||
|
export * from "./change-issue-assignee";
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -1,12 +1,8 @@
|
|||||||
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 "./feeds";
|
export * from "./feeds";
|
||||||
|
export * from "./reaction-selector";
|
||||||
|
export * from "./image-picker-popover";
|
||||||
|
@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -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.",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
|
87
apps/app/components/core/reaction-selector.tsx
Normal file
87
apps/app/components/core/reaction-selector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
201
apps/app/components/core/views/all-views.tsx
Normal file
201
apps/app/components/core/views/all-views.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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}
|
@ -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>
|
@ -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 && (
|
@ -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
|
@ -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}
|
@ -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}
|
||||||
/>
|
/>
|
@ -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
|
@ -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";
|
||||||
|
|
7
apps/app/components/core/views/index.ts
Normal file
7
apps/app/components/core/views/index.ts
Normal 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";
|
@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
62
apps/app/components/core/views/list-view/all-lists.tsx
Normal file
62
apps/app/components/core/views/list-view/all-lists.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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}
|
@ -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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
@ -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}
|
@ -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={
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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">
|
||||||
|
@ -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({
|
||||||
|
@ -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>
|
||||||
|
@ -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 (
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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 <></>;
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
})),
|
})),
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
@ -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} />
|
||||||
|
@ -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,
|
||||||
|
@ -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 { 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";
|
import { activityDetails } from "helpers/activity.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,79 @@ 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") {
|
? activityDetails[activityItem.field as keyof typeof activityDetails]?.message(
|
||||||
action = activityItem.new_value !== "" ? "added a new label" : "removed the label";
|
activityItem
|
||||||
} else if (activityItem.field === "blocking") {
|
)
|
||||||
action =
|
: "created the issue.";
|
||||||
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
|
activityDetails[activityItem.field as keyof typeof activityDetails]
|
||||||
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
|
?.icon
|
||||||
>
|
)
|
||||||
{activityItem.actor_detail.first_name.charAt(0)}
|
) : activityItem.actor_detail.avatar &&
|
||||||
</div>
|
activityItem.actor_detail.avatar !== "" ? (
|
||||||
)}
|
<img
|
||||||
</div>
|
src={activityItem.actor_detail.avatar}
|
||||||
|
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 +170,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}
|
||||||
/>
|
/>
|
||||||
|
@ -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!",
|
@ -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>
|
||||||
))}
|
))}
|
@ -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",
|
3
apps/app/components/issues/attachment/index.ts
Normal file
3
apps/app/components/issues/attachment/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./attachment-upload";
|
||||||
|
export * from "./attachments";
|
||||||
|
export * from "./delete-attachment-modal";
|
@ -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,12 @@ 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
|
||||||
|
workspaceSlug={comment?.workspace_detail?.slug}
|
||||||
|
projectId={comment.project}
|
||||||
|
commentId={comment.id}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
88
apps/app/components/issues/comment/comment-reaction.tsx
Normal file
88
apps/app/components/issues/comment/comment-reaction.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// 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 = {
|
||||||
|
workspaceSlug?: string | string[];
|
||||||
|
projectId?: string | string[];
|
||||||
|
commentId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommentReaction: React.FC<Props> = (props) => {
|
||||||
|
const { workspaceSlug, projectId, commentId } = props;
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,2 +1,3 @@
|
|||||||
export * from "./add-comment";
|
export * from "./add-comment";
|
||||||
export * from "./comment-card";
|
export * from "./comment-card";
|
||||||
|
export * from "./comment-reaction";
|
||||||
|
@ -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();
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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"
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
|
export * from "./attachment";
|
||||||
export * from "./comment";
|
export * from "./comment";
|
||||||
|
export * from "./my-issues";
|
||||||
export * from "./sidebar-select";
|
export * from "./sidebar-select";
|
||||||
export * from "./view-select";
|
export * from "./view-select";
|
||||||
export * from "./activity";
|
export * from "./activity";
|
||||||
export * from "./delete-issue-modal";
|
export * from "./delete-issue-modal";
|
||||||
export * from "./description-form";
|
export * from "./description-form";
|
||||||
export * from "./form";
|
export * from "./form";
|
||||||
|
export * from "./gantt-chart";
|
||||||
export * from "./main-content";
|
export * from "./main-content";
|
||||||
export * from "./modal";
|
export * from "./modal";
|
||||||
export * from "./my-issues-list-item";
|
|
||||||
export * from "./parent-issues-list-modal";
|
export * from "./parent-issues-list-modal";
|
||||||
export * from "./sidebar";
|
export * from "./sidebar";
|
||||||
export * from "./sub-issues-list";
|
export * from "./sub-issues-list";
|
||||||
export * from "./attachment-upload";
|
export * from "./label";
|
||||||
export * from "./attachments";
|
export * from "./issue-reaction";
|
||||||
export * from "./delete-attachment-modal";
|
|
||||||
|
78
apps/app/components/issues/issue-reaction.tsx
Normal file
78
apps/app/components/issues/issue-reaction.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
// hooks
|
||||||
|
import useUserAuth from "hooks/use-user-auth";
|
||||||
|
import useIssueReaction from "hooks/use-issue-reaction";
|
||||||
|
// components
|
||||||
|
import { ReactionSelector } from "components/core";
|
||||||
|
// string helpers
|
||||||
|
import { renderEmoji } from "helpers/emoji.helper";
|
||||||
|
|
||||||
|
// types
|
||||||
|
type Props = {
|
||||||
|
workspaceSlug?: string | string[];
|
||||||
|
projectId?: string | string[];
|
||||||
|
issueId?: string | string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IssueReaction: React.FC<Props> = (props) => {
|
||||||
|
const { workspaceSlug, projectId, issueId } = props;
|
||||||
|
|
||||||
|
const { user } = useUserAuth();
|
||||||
|
|
||||||
|
const { reactions, groupedReactions, handleReactionCreate, handleReactionDelete } =
|
||||||
|
useIssueReaction(workspaceSlug, projectId, issueId);
|
||||||
|
|
||||||
|
const handleReactionClick = (reaction: string) => {
|
||||||
|
if (!workspaceSlug || !projectId || !issueId) return;
|
||||||
|
|
||||||
|
const isSelected = reactions?.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-4">
|
||||||
|
<ReactionSelector
|
||||||
|
size="md"
|
||||||
|
position="top"
|
||||||
|
value={
|
||||||
|
reactions?.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 ${
|
||||||
|
reactions?.some((r) => r.actor === user?.id && r.reaction === reaction)
|
||||||
|
? "bg-custom-primary-100/10"
|
||||||
|
: "bg-custom-background-80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{renderEmoji(reaction)}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
reactions?.some((r) => r.actor === user?.id && r.reaction === reaction)
|
||||||
|
? "text-custom-primary-100"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{groupedReactions?.[reaction].length}{" "}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
55
apps/app/components/issues/label.tsx
Normal file
55
apps/app/components/issues/label.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { Tooltip } from "components/ui";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
issue: IIssue;
|
||||||
|
maxRender?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ViewIssueLabel: React.FC<Props> = ({ issue, maxRender = 1 }) => (
|
||||||
|
<>
|
||||||
|
{issue.label_details.length > 0 ? (
|
||||||
|
issue.label_details.length <= maxRender ? (
|
||||||
|
<>
|
||||||
|
{issue.label_details.map((label, index) => (
|
||||||
|
<div
|
||||||
|
key={label.id}
|
||||||
|
className="flex cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
|
||||||
|
>
|
||||||
|
<Tooltip position="top" tooltipHeading="Label" tooltipContent={label.name}>
|
||||||
|
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: label?.color ?? "#000000",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{label.name}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm">
|
||||||
|
<Tooltip
|
||||||
|
position="top"
|
||||||
|
tooltipHeading="Labels"
|
||||||
|
tooltipContent={issue.label_details.map((l) => l.name).join(", ")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||||
|
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" />
|
||||||
|
{`${issue.label_details.length} Labels`}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user