Merge pull request #1758 from makeplane/stage-release

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

View File

@ -2,7 +2,7 @@
<p align="center">
<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>
</p>
@ -11,22 +11,22 @@
<p align="center">
<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>
<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>
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
<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"
width="100%"
/>
</a>
<a href="https://app.plane.so/#gh-dark-mode-only" target="_blank">
<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"
width="100%"
/>
@ -86,7 +86,7 @@ docker compose up -d
<p>
<a href="https://plane.so" target="_blank">
<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"
width="100%"
/>
@ -95,7 +95,7 @@ docker compose up -d
<p>
<a href="https://plane.so" target="_blank">
<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"
width="100%"
/>
@ -104,7 +104,7 @@ docker compose up -d
<p>
<a href="https://plane.so" target="_blank">
<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"
width="100%"
/>
@ -113,7 +113,7 @@ docker compose up -d
<p>
<a href="https://plane.so" target="_blank">
<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"
width="100%"
/>
@ -122,7 +122,7 @@ docker compose up -d
<p>
<a href="https://plane.so" target="_blank">
<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"
width="100%"
/>
@ -132,7 +132,7 @@ docker compose up -d
<p>
<a href="https://plane.so" target="_blank">
<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"
width="100%"
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -45,6 +45,11 @@ from plane.api.views import (
UserIssueCompletedGraphEndpoint,
UserWorkspaceDashboardEndpoint,
WorkspaceThemeViewSet,
WorkspaceUserProfileStatsEndpoint,
WorkspaceUserActivityEndpoint,
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
## End Workspaces
# File Assets
FileAssetEndpoint,
@ -79,6 +84,8 @@ from plane.api.views import (
IssueAttachmentEndpoint,
IssueArchiveViewSet,
IssueSubscriberViewSet,
IssueReactionViewSet,
CommentReactionViewSet,
## End Issues
# States
StateViewSet,
@ -385,6 +392,31 @@ urlpatterns = [
),
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 ##
# Projects
path(
@ -836,6 +868,48 @@ urlpatterns = [
name="project-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
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/",
@ -1240,7 +1314,7 @@ urlpatterns = [
## End Importer
# Search
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/search/",
"workspaces/<str:slug>/search/",
GlobalSearchEndpoint.as_view(),
name="global-search",
),

View File

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

View File

@ -279,6 +279,8 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Clean up
email = email.strip().lower()
validate_email(email)
## Generate a random token
@ -346,7 +348,7 @@ class MagicSignInEndpoint(BaseAPIView):
def post(self, request):
try:
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 == "":
return Response(

View File

@ -31,6 +31,7 @@ from plane.api.serializers import (
CycleIssueSerializer,
CycleFavoriteSerializer,
IssueStateSerializer,
CycleWriteSerializer,
)
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import (
@ -338,7 +339,7 @@ class CycleViewSet(BaseViewSet):
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():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
@ -691,7 +692,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
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",
"cycles": CycleSerializer(cycles, many=True).data,
"status": False,
}
)

View File

@ -46,8 +46,11 @@ from plane.api.serializers import (
IssueAttachmentSerializer,
IssueSubscriberSerializer,
ProjectMemberLiteSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
)
from plane.api.permissions import (
WorkspaceEntityPermission,
ProjectEntityPermission,
WorkSpaceAdminPermission,
ProjectMemberPermission,
@ -65,6 +68,8 @@ from plane.db.models import (
State,
IssueSubscriber,
ProjectMember,
IssueReaction,
CommentReaction,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@ -151,13 +156,19 @@ class IssueViewSet(BaseViewSet):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
try:
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
priority_order = ["urgent", "high", "medium", "low", None]
@ -244,12 +255,6 @@ class IssueViewSet(BaseViewSet):
else:
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
## Grouping the results
@ -262,7 +267,7 @@ class IssueViewSet(BaseViewSet):
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
@ -270,9 +275,15 @@ class IssueViewSet(BaseViewSet):
def create(self, request, slug, project_id):
try:
project = Project.objects.get(workspace__slug=slug, pk=project_id)
project = Project.objects.get(pk=project_id)
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():
@ -311,9 +322,17 @@ class UserWorkSpaceIssues(BaseAPIView):
@method_decorator(gzip_page)
def get(self, request, slug):
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(
assignees__in=[request.user], workspace__slug=slug
(Q(assignees__in=[request.user]) | Q(created_by=request.user)),
workspace__slug=slug,
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
@ -327,7 +346,7 @@ class UserWorkSpaceIssues(BaseAPIView):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.order_by("-created_at")
.order_by(order_by_param)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@ -342,9 +361,77 @@ class UserWorkSpaceIssues(BaseAPIView):
.annotate(count=Func(F("id"), function="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:
capture_exception(e)
return Response(
@ -390,12 +477,13 @@ class IssueActivityEndpoint(BaseAPIView):
~Q(field="comment"),
project__project_projectmember__member=self.request.user,
)
.select_related("actor", "workspace")
.select_related("actor", "workspace", "issue", "project")
).order_by("created_at")
issue_comments = (
IssueComment.objects.filter(issue_id=issue_id)
.filter(project__project_projectmember__member=self.request.user)
.order_by("created_at")
.select_related("actor", "issue", "project", "workspace")
)
issue_activities = IssueActivitySerializer(issue_activities, many=True).data
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
@ -418,7 +506,7 @@ class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
permission_classes = [
ProjectEntityPermission,
ProjectLitePermission,
]
filterset_fields = [
@ -628,9 +716,7 @@ class SubIssuesEndpoint(BaseAPIView):
def get(self, request, slug, project_id, issue_id):
try:
sub_issues = (
Issue.issue_objects.filter(
parent_id=issue_id, workspace__slug=slug, project_id=project_id
)
Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
@ -660,9 +746,7 @@ class SubIssuesEndpoint(BaseAPIView):
)
state_distribution = (
State.objects.filter(
~Q(name="Triage"), workspace__slug=slug, project_id=project_id
)
State.objects.filter(~Q(name="Triage"), workspace__slug=slug)
.annotate(
state_count=Count(
"state_issue",
@ -1096,7 +1180,8 @@ class IssueArchiveViewSet(BaseViewSet):
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
except Issue.DoesNotExist:
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:
capture_exception(e)
return Response(
@ -1104,6 +1189,7 @@ class IssueArchiveViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
class IssueSubscriberViewSet(BaseViewSet):
serializer_class = IssueSubscriberSerializer
model = IssueSubscriber
@ -1144,18 +1230,22 @@ class IssueSubscriberViewSet(BaseViewSet):
def list(self, request, slug, project_id, issue_id):
try:
members = ProjectMember.objects.filter(
workspace__slug=slug, project_id=project_id
).annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
subscriber=OuterRef("member"),
members = (
ProjectMember.objects.filter(
workspace__slug=slug, project_id=project_id
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
subscriber=OuterRef("member"),
)
)
)
).select_related("member")
.select_related("member")
)
serializer = ProjectMemberLiteSerializer(members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
@ -1255,3 +1345,103 @@ class IssueSubscriberViewSet(BaseViewSet):
{"error": "Something went wrong, please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueReactionViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
permission_classes = [
ProjectLitePermission,
]
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user)
.order_by("-created_at")
.distinct()
)
def perform_create(self, serializer):
serializer.save(
issue_id=self.kwargs.get("issue_id"),
project_id=self.kwargs.get("project_id"),
actor=self.request.user,
)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
try:
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
reaction=reaction_code,
actor=request.user,
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist:
return Response(
{"error": "Issue reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class CommentReactionViewSet(BaseViewSet):
serializer_class = CommentReactionSerializer
model = CommentReaction
permission_classes = [
ProjectLitePermission,
]
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(comment_id=self.kwargs.get("comment_id"))
.filter(project__project_projectmember__member=self.request.user)
.order_by("-created_at")
.distinct()
)
def perform_create(self, serializer):
serializer.save(
actor=self.request.user,
comment_id=self.kwargs.get("comment_id"),
project_id=self.kwargs.get("project_id"),
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
try:
comment_reaction = CommentReaction.objects.get(
workspace__slug=slug,
project_id=project_id,
comment_id=comment_id,
reaction=reaction_code,
actor=request.user,
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist:
return Response(
{"error": "Comment reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -6,14 +6,15 @@ from django.utils import timezone
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
from plane.utils.paginator import BasePaginator
# Module imports
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
class NotificationViewSet(BaseViewSet):
class NotificationViewSet(BaseViewSet, BasePaginator):
model = Notification
serializer_class = NotificationSerializer
@ -37,9 +38,13 @@ class NotificationViewSet(BaseViewSet):
# Filter type
type = request.GET.get("type", "all")
notifications = Notification.objects.filter(
workspace__slug=slug, receiver_id=request.user.id
).order_by("snoozed_till", "-created_at")
notifications = (
Notification.objects.filter(
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
if snoozed == "false":
@ -78,10 +83,23 @@ class NotificationViewSet(BaseViewSet):
# Created issues
if type == "created":
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)
if WorkspaceMember.objects.filter(workspace__slug=slug, member=request.user, role__lt=15).exists():
notifications = Notification.objects.none()
else:
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)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

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

View File

@ -5,7 +5,7 @@ from datetime import datetime
# Django imports
from django.core.exceptions import ValidationError
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.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()
)
@ -102,10 +120,16 @@ class ProjectViewSet(BaseViewSet):
project_id=OuterRef("pk"),
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 = (
self.get_queryset()
.annotate(is_favorite=Exists(subquery))
.order_by("-is_favorite", "name")
.annotate(sort_order=Subquery(sort_order_query))
.order_by("sort_order", "name")
.annotate(
total_members=ProjectMember.objects.filter(
project_id=OuterRef("id")
@ -152,10 +176,17 @@ class ProjectViewSet(BaseViewSet):
serializer.save()
# 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
)
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
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(
[serializer.errors[error][0] for error in serializer.errors],
serializer.errors,
status=status.HTTP_400_BAD_REQUEST,
)
except IntegrityError as e:
@ -567,17 +600,26 @@ class AddMemberToProjectEndpoint(BaseAPIView):
{"error": "Atleast one member is required"},
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(
member_id=member.get("member_id"),
role=member.get("role", 10),
project_id=project_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,
ignore_conflicts=True,
)
@ -819,11 +861,15 @@ class ProjectUserViewsEndpoint(BaseAPIView):
view_props = project_member.view_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.default_props = request.data.get(
"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()

View File

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

View File

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

View File

@ -70,7 +70,7 @@ def track_parent(
issue_id=issue_id,
actor=actor,
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,
field="parent",
project=project,
@ -88,10 +88,10 @@ def track_parent(
issue_id=issue_id,
actor=actor,
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
else None,
new_value=f"{project.identifier}-{new_parent.sequence_id}",
new_value=f"{new_parent.project.identifier}-{new_parent.sequence_id}",
field="parent",
project=project,
workspace=project.workspace,
@ -376,7 +376,7 @@ def track_assignees(
verb="updated",
old_value=assignee.email,
new_value="",
field="assignee",
field="assignees",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed assignee {assignee.email}",
@ -415,11 +415,11 @@ def track_blocks(
actor=actor,
verb="updated",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field="blocks",
project=project,
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,
)
)
@ -436,12 +436,12 @@ def track_blocks(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}",
old_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value="",
field="blocks",
project=project,
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,
)
)
@ -477,11 +477,11 @@ def track_blockings(
actor=actor,
verb="updated",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field="blocking",
project=project,
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,
)
)
@ -498,12 +498,12 @@ def track_blockings(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}",
old_value=f"{issue.project.identifier}-{issue.sequence_id}",
new_value="",
field="blocking",
project=project,
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,
)
)
@ -959,6 +959,11 @@ def update_link_activity(
def delete_link_activity(
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(
IssueActivity(
issue_id=issue_id,
@ -968,6 +973,8 @@ def delete_link_activity(
verb="deleted",
actor=actor,
field="link",
old_value=current_instance.get("url", ""),
new_value=""
)
)
@ -989,7 +996,7 @@ def create_attachment_activity(
verb="created",
actor=actor,
field="attachment",
new_value=current_instance.get("access", ""),
new_value=current_instance.get("asset", ""),
new_identifier=current_instance.get("id", None),
)
)
@ -1034,11 +1041,14 @@ def issue_activity(
"module.activity.created",
"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:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
try:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
except Exception as e:
pass
if subscriber:
# add the user to issue subscriber
@ -1122,7 +1132,7 @@ def issue_activity(
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
if (
@ -1149,7 +1159,7 @@ def issue_activity(
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(project.identifier),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,

View File

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

View File

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

View File

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

View File

@ -209,7 +209,7 @@ class IssueAssignee(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()
issue = models.ForeignKey(
"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}"
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
@receiver(post_save, sender=Issue)
def create_issue_sequence(sender, instance, created, **kwargs):

View File

@ -31,6 +31,13 @@ def get_default_props():
"showEmptyGroups": True,
}
def get_default_preferences():
return {
"pages": {
"block_display": True
}
}
class Project(BaseModel):
NETWORK_CHOICES = ((0, "Secret"), (2, "Public"))
@ -47,7 +54,7 @@ class Project(BaseModel):
"db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project"
)
identifier = models.CharField(
max_length=5,
max_length=12,
verbose_name="Project Identifier",
)
default_assignee = models.ForeignKey(
@ -147,6 +154,21 @@ class ProjectMember(ProjectBaseModel):
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
view_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:
unique_together = ["project", "member"]
@ -168,7 +190,7 @@ class ProjectIdentifier(AuditModel):
project = models.OneToOneField(
Project, on_delete=models.CASCADE, related_name="project_identifier"
)
name = models.CharField(max_length=10)
name = models.CharField(max_length=12)
class Meta:
unique_together = ["name", "workspace"]

View File

@ -38,6 +38,7 @@ class User(AbstractBaseUser, PermissionsMixin):
first_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)
cover_image = models.URLField(blank=True, null=True, max_length=800)
# tracking metrics
date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Created At")

View File

@ -14,6 +14,30 @@ ROLE_CHOICES = (
)
def get_default_props():
return {
"filters": {"type": None},
"groupByProperty": None,
"issueView": "list",
"orderBy": "-created_at",
"properties": {
"assignee": True,
"due_date": True,
"key": True,
"labels": True,
"priority": True,
"state": True,
"sub_issue_count": True,
"attachment_count": True,
"link": True,
"estimate": True,
"created_on": True,
"updated_on": True,
},
"showEmptyGroups": True,
}
class Workspace(BaseModel):
name = models.CharField(max_length=80, verbose_name="Workspace Name")
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)
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:
unique_together = ["workspace", "member"]

View File

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

View File

@ -90,7 +90,7 @@
<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;">
<!--[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/>
<div style="display:none;">
<center class="default-button">

View File

@ -91,7 +91,7 @@
<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;">
<!--[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/>
<div style="display:none;">
<center class="default-button">

View File

@ -91,7 +91,7 @@
<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;">
<!--[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/>
<div style="display:none;">
<center class="default-button">

View File

@ -227,12 +227,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
</span>
) : project.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
<span
style={{ color: project.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{project.icon_prop.name}
</span>
{renderEmoji(project.icon_prop)}
</div>
) : (
<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>
) : projectDetails?.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
<span
style={{ color: projectDetails.icon_prop.color }}
className="material-symbols-rounded text-lg"
>
{projectDetails.icon_prop.name}
</span>
{renderEmoji(projectDetails.icon_prop)}
</div>
) : (
<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">
<h6 className="text-custom-text-200">Network</h6>
<span>
{
NETWORK_CHOICES[
`${projectDetails?.network}` as keyof typeof NETWORK_CHOICES
]
}
{NETWORK_CHOICES.find((n) => n.key === projectDetails?.network)?.label ??
""}
</span>
</div>
</div>

View File

@ -28,7 +28,7 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
handleClose={() => setmonthModal(false)}
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 flex-col gap-2.5">
<h4 className="text-lg font-semibold">Auto-archive closed issues</h4>

View File

@ -37,8 +37,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups ?? {});
const states = getStatesList(stateGroups);
const options = states
?.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 selectedOption = states?.find(
(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> = {
close_in: 1,
@ -77,7 +76,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
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 flex-col gap-2.5">
<h4 className="text-lg font-semibold">Auto-close inactive issues</h4>

View File

@ -2,7 +2,6 @@ import * as React from "react";
import { useRouter } from "next/router";
import Link from "next/link";
// icons
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import { Icon } from "components/ui";
type BreadcrumbsProps = {
@ -14,7 +13,7 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
return (
<>
<div className="flex items-center">
<div className="flex items-center flex-grow w-full whitespace-nowrap overflow-hidden overflow-ellipsis">
<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"
@ -35,22 +34,36 @@ type BreadcrumbItemProps = {
title: string;
link?: string;
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 href={link}>
<a className="border-r-2 border-custom-sidebar-border-200 px-3 text-sm">
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
<a
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}
{title}
</p>
</a>
</Link>
) : (
<div className="max-w-64 px-3 text-sm">
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
<div className={`px-3 text-sm truncate ${unshrinkTitle ? "flex-shrink-0" : ""}`}>
<p className={`truncate ${icon ? "flex items-center gap-2" : ""}`}>
{icon}
<span className="break-words">{title}</span>
</p>

View File

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

View File

@ -1,53 +1,15 @@
import { useRouter } from "next/router";
import React, { useCallback, useEffect, useState } from "react";
import useSWR, { mutate } from "swr";
// icons
import {
ArrowRightIcon,
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";
import { useRouter } from "next/router";
import useSWR from "swr";
// hooks
import useProjectDetails from "hooks/use-project-details";
import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
import useDebounce from "hooks/use-debounce";
// components
import {
ShortcutsModal,
ChangeIssueState,
ChangeIssuePriority,
ChangeIssueAssignee,
ChangeInterfaceTheme,
} from "components/command-palette";
import { CommandK, ShortcutsModal } from "components/command-palette";
import { BulkDeleteIssuesModal } from "components/core";
import { CreateUpdateCycleModal } from "components/cycles";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
@ -55,22 +17,13 @@ import { CreateUpdateModuleModal } from "components/modules";
import { CreateProjectModal } from "components/project";
import { CreateUpdateViewModal } from "components/views";
import { CreateUpdatePageModal } from "components/pages";
import { Spinner } from "components/ui";
// helpers
import {
capitalizeFirstLetter,
copyTextToClipboard,
replaceUnderscoreIfSnakeCase,
} from "helpers/string.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// services
import issuesService from "services/issues.service";
import workspaceService from "services/workspace.service";
import inboxService from "services/inbox.service";
// types
import { IIssue, IWorkspaceSearchResults } from "types";
// 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 = () => {
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
@ -84,36 +37,15 @@ export const CommandPalette: React.FC = () => {
const [deleteIssueModal, setDeleteIssueModal] = 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 { workspaceSlug, projectId, issueId, inboxId } = router.query;
const { user } = useUser();
const { projectDetails } = useProjectDetails();
const { setToastAlert } = useToast();
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
? () =>
@ -121,59 +53,6 @@ export const CommandPalette: React.FC = () => {
: 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(() => {
if (!router.query.issueId) return;
@ -246,98 +125,17 @@ export const CommandPalette: React.FC = () => {
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", 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;
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 = () => {
setIsPaletteOpen(false);
setDeleteIssueModal(true);
};
const redirect = (path: string) => {
setIsPaletteOpen(false);
router.push(path);
};
return (
<>
<ShortcutsModal isOpen={isShortcutsModalOpen} setIsOpen={setIsShortcutsModalOpen} />
@ -390,538 +188,11 @@ export const CommandPalette: React.FC = () => {
setIsOpen={setIsBulkDeleteIssuesModalOpen}
user={user}
/>
<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...");
}
}}
>
{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>
<CommandK
deleteIssue={deleteIssue}
isPaletteOpen={isPaletteOpen}
setIsPaletteOpen={setIsPaletteOpen}
/>
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,15 @@
import React, { useEffect, useState, useRef } from "react";
import React, { useEffect, useState, useRef, useCallback } from "react";
// next
import Image from "next/image";
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// react-dropdown
import { useDropzone } from "react-dropzone";
// headless ui
import { Tab, Transition, Popover } from "@headlessui/react";
@ -10,9 +17,9 @@ import { Tab, Transition, Popover } from "@headlessui/react";
import fileService from "services/file.service";
// components
import { Input, Spinner, PrimaryButton } from "components/ui";
import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
import useWorkspaceDetails from "hooks/use-workspace-details";
const unsplashEnabled =
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
@ -38,6 +45,12 @@ type Props = {
export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange }) => {
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 [searchParams, setSearchParams] = useState("");
const [formData, setFormData] = useState({
@ -48,10 +61,50 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
fileService.getUnsplashImages(1, searchParams)
);
useOutsideClickDetector(ref, () => {
setIsOpen(false);
const { workspaceDetails } = useWorkspaceDetails();
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(() => {
if (!images || value !== null) return;
onChange(images[0].urls.regular);
@ -62,7 +115,7 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
return (
<Popover className="relative z-[2]" ref={ref}>
<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)}
>
{label}
@ -77,22 +130,24 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
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">
<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.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
{tabOptions.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`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.List>
<div>
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
{tabOptions.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`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.List>
</div>
<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">
<div className="flex gap-x-2 pt-7">
@ -133,8 +188,78 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
</div>
)}
</Tab.Panel>
<Tab.Panel className="flex h-full w-full flex-col items-center justify-center">
<p>Coming Soon...</p>
<Tab.Panel className="h-full w-full pt-5">
<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.Panels>
</Tab.Group>

View File

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

View File

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

View File

@ -13,8 +13,9 @@ import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view";
import useDebounce from "hooks/use-debounce";
// ui
import { Loader, PrimaryButton, SecondaryButton } from "components/ui";
import { Loader, PrimaryButton, SecondaryButton, ToggleSwitch, Tooltip } from "components/ui";
// icons
import { LaunchOutlined } from "@mui/icons-material";
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// types
@ -32,6 +33,7 @@ type Props = {
handleClose: () => void;
searchParams: Partial<TProjectIssuesSearchParams>;
handleOnSubmit: (data: ISearchIssueResponse[]) => Promise<void>;
workspaceLevelToggle?: boolean;
};
export const ExistingIssuesListModal: React.FC<Props> = ({
@ -39,13 +41,14 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
handleClose: onClose,
searchParams,
handleOnSubmit,
workspaceLevelToggle = false,
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [selectedIssues, setSelectedIssues] = useState<ISearchIssueResponse[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
@ -60,6 +63,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
onClose();
setSearchTerm("");
setSelectedIssues([]);
setIsWorkspaceLevel(false);
};
const onSubmit = async () => {
@ -97,31 +101,19 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
};
useEffect(() => {
if (!workspaceSlug || !projectId) return;
if (!isOpen || !workspaceSlug || !projectId) return;
setIsLoading(true);
setIsSearching(true);
if (debouncedSearchTerm) {
setIsSearching(true);
projectService
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
search: debouncedSearchTerm,
...searchParams,
})
.then((res) => {
setIssues(res);
})
.finally(() => {
setIsLoading(false);
setIsSearching(false);
});
} else {
setIssues([]);
setIsLoading(false);
setIsSearching(false);
}
}, [debouncedSearchTerm, workspaceSlug, projectId, searchParams]);
projectService
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
search: debouncedSearchTerm,
...searchParams,
workspace_search: isWorkspaceLevel,
})
.then((res) => setIssues(res))
.finally(() => setIsSearching(false));
}, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]);
return (
<>
@ -169,14 +161,14 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
aria-hidden="true"
/>
<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..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</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 ? (
<div className="flex items-center gap-2 flex-wrap mt-1">
{selectedIssues.map((issue) => (
@ -204,22 +196,43 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
No issues selected
</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>
<Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto mt-2">
{debouncedSearchTerm !== "" && (
<Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto">
{searchTerm !== "" && (
<h5 className="text-[0.825rem] text-custom-text-200 mx-2">
Search results for{" "}
<span className="text-custom-text-100">
{'"'}
{debouncedSearchTerm}
{searchTerm}
{'"'}
</span>{" "}
in project:
</h5>
)}
{!isLoading &&
{!isSearching &&
issues.length === 0 &&
searchTerm !== "" &&
debouncedSearchTerm !== "" && (
@ -235,7 +248,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
</div>
)}
{isLoading || isSearching ? (
{isSearching ? (
<Loader className="space-y-3 p-3">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
@ -256,22 +269,37 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
htmlFor={`issue-${issue.id}`}
value={issue}
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" : ""
} ${selected ? "text-custom-text-100" : ""}`
}
>
<input type="checkbox" checked={selected} readOnly />
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state__color,
}}
/>
<span className="flex-shrink-0 text-xs">
{issue.project__identifier}-{issue.sequence_id}
</span>
{issue.name}
<div className="flex items-center gap-2">
<input type="checkbox" checked={selected} readOnly />
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state__color,
}}
/>
<span className="flex-shrink-0 text-xs">
{issue.project__identifier}-{issue.sequence_id}
</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>
);
})}

View File

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

View File

@ -43,8 +43,12 @@ export const ImageUploadModal: React.FC<Props> = ({
setImage(acceptedFiles[0]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"],
},
maxSize: 5 * 1024 * 1024,
});
const handleSubmit = async () => {
@ -166,9 +170,19 @@ export const ImageUploadModal: React.FC<Props> = ({
<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>
)}
</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>
<PrimaryButton
onClick={handleSubmit}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -192,7 +192,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
</CustomMenu>
</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">
{properties.key && (
<Tooltip

View File

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

View File

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

View File

@ -5,43 +5,31 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { DropResult } from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
import stateService from "services/state.service";
import modulesService from "services/modules.service";
import trackEventServices from "services/track-event.service";
// contexts
import { useProjectMyMembership } from "contexts/project-member.context";
// hooks
import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view";
import useUserAuth from "hooks/use-user-auth";
import useIssuesProperties from "hooks/use-issue-properties";
import useProjectMembers from "hooks/use-project-members";
// components
import {
AllLists,
AllBoards,
FilterList,
CalendarView,
GanttChartView,
SpreadsheetView,
} from "components/core";
import { FiltersList, AllViews } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { CreateUpdateViewModal } from "components/views";
import { TransferIssues, TransferIssuesModal } from "components/cycles";
// ui
import { EmptyState, PrimaryButton, Spinner, Icon } from "components/ui";
import { PrimaryButton, SecondaryButton } from "components/ui";
// icons
import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
// images
import emptyIssue from "public/empty-state/issue.svg";
import emptyIssueArchive from "public/empty-state/issue-archive.svg";
import { PlusIcon } from "@heroicons/react/24/outline";
// helpers
import { getStatesList } from "helpers/state.helper";
import { orderArrayBy } from "helpers/array.helper";
// types
import { IIssue, IIssueFilterOptions } from "types";
import { IIssue, IIssueFilterOptions, IState } from "types";
// fetch-keys
import {
CYCLE_DETAILS,
@ -49,19 +37,18 @@ import {
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
PROJECT_ISSUE_LABELS,
STATES_LIST,
} from "constants/fetch-keys";
type Props = {
type?: "issue" | "cycle" | "module";
openIssuesListModal?: () => void;
isCompleted?: boolean;
disableUserActions?: boolean;
};
export const IssuesView: React.FC<Props> = ({
type = "issue",
openIssuesListModal,
isCompleted = false,
disableUserActions = false,
}) => {
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
@ -83,20 +70,16 @@ export const IssuesView: React.FC<Props> = ({
// trash box
const [trashBox, setTrashBox] = useState(false);
// transfer issue
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { memberRole } = useProjectMyMembership();
const { user } = useUserAuth();
const { setToastAlert } = useToast();
const {
groupedByIssues,
mutateIssues,
issueView,
groupByProperty: selectedGroup,
orderBy,
@ -104,7 +87,9 @@ export const IssuesView: React.FC<Props> = ({
isEmpty,
setFilters,
params,
showEmptyGroups,
} = useIssuesView();
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const { data: stateGroups } = useSWR(
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)
: 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(
(issue: IIssue) => {
@ -123,7 +117,7 @@ export const IssuesView: React.FC<Props> = ({
);
const handleOnDragEnd = useCallback(
(result: DropResult) => {
async (result: DropResult) => {
setTrashBox(false);
if (!result.destination || !workspaceSlug || !projectId || !groupedByIssues) return;
@ -191,7 +185,10 @@ export const IssuesView: React.FC<Props> = ({
// dragged item(or issue)
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
@ -207,8 +204,8 @@ export const IssuesView: React.FC<Props> = ({
(prevData) => {
if (!prevData) return prevData;
const sourceGroupArray = prevData[sourceGroup];
const destinationGroupArray = groupedByIssues[destinationGroup];
const sourceGroupArray = [...groupedByIssues[sourceGroup]];
const destinationGroupArray = [...groupedByIssues[destinationGroup]];
sourceGroupArray.splice(source.index, 1);
destinationGroupArray.splice(destination.index, 0, draggedItem);
@ -236,7 +233,9 @@ export const IssuesView: React.FC<Props> = ({
user
)
.then((response) => {
const sourceStateBeforeDrag = states.find((state) => state.name === source.droppableId);
const sourceStateBeforeDrag = states?.find(
(state) => state.name === source.droppableId
);
if (
sourceStateBeforeDrag?.group !== "completed" &&
@ -281,7 +280,7 @@ export const IssuesView: React.FC<Props> = ({
]
);
const addIssueToState = useCallback(
const addIssueToGroup = useCallback(
(groupTitle: string) => {
setCreateIssueModal(true);
@ -335,6 +334,15 @@ export const IssuesView: React.FC<Props> = ({
[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(
(bridgeId: string, issueId: string) => {
if (!workspaceSlug || !projectId || !cycleId) return;
@ -421,13 +429,6 @@ export const IssuesView: React.FC<Props> = ({
[workspaceSlug, projectId, moduleId, params, selectedGroup, setToastAlert]
);
const handleTrashBox = useCallback(
(isDragging: boolean) => {
if (isDragging && !trashBox) setTrashBox(true);
},
[trashBox, setTrashBox]
);
const nullFilters = Object.keys(filters).filter(
(key) => filters[key as keyof IIssueFilterOptions] === null
);
@ -461,14 +462,27 @@ export const IssuesView: React.FC<Props> = ({
data={issueToDelete}
user={user}
/>
<TransferIssuesModal
handleClose={() => setTransferIssuesModal(false)}
isOpen={transferIssuesModal}
/>
{areFiltersApplied && (
<>
<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
onClick={() => {
if (viewId) {
@ -492,129 +506,62 @@ export const IssuesView: React.FC<Props> = ({
{<div className="mt-3 border-t border-custom-border-200" />}
</>
)}
<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>
{groupedByIssues ? (
!isEmpty || issueView === "kanban" || issueView === "calendar" ? (
<>
{isCompleted && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />}
{issueView === "list" ? (
<AllLists
type={type}
states={states}
addIssueToState={addIssueToState}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={
type === "cycle"
? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
isCompleted={isCompleted}
user={user}
userAuth={memberRole}
/>
) : issueView === "kanban" ? (
<AllBoards
type={type}
states={states}
addIssueToState={addIssueToState}
makeIssueCopy={makeIssueCopy}
handleEditIssue={handleEditIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
handleDeleteIssue={handleDeleteIssue}
handleTrashBox={handleTrashBox}
removeIssue={
type === "cycle"
? 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>
<AllViews
addIssueToDate={addIssueToDate}
addIssueToGroup={addIssueToGroup}
disableUserActions={disableUserActions}
dragDisabled={
selectedGroup === "created_by" ||
selectedGroup === "labels" ||
selectedGroup === "state_detail.group"
}
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.",
primaryButton: {
icon: <PlusIcon className="h-4 w-4" />,
text: "New Issue",
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
},
},
secondaryButton:
cycleId || moduleId ? (
<SecondaryButton
className="flex items-center gap-1.5"
onClick={openIssuesListModal ?? (() => {})}
>
<PlusIcon className="h-4 w-4" />
Add an existing issue
</SecondaryButton>
) : null,
}}
handleOnDragEnd={handleOnDragEnd}
handleIssueAction={handleIssueAction}
openIssuesListModal={openIssuesListModal ? openIssuesListModal : null}
removeIssue={cycleId ? removeIssueFromCycle : moduleId ? removeIssueFromModule : null}
trashBox={trashBox}
setTrashBox={setTrashBox}
viewProps={{
groupByProperty: selectedGroup,
groupedIssues: groupedByIssues,
isEmpty,
issueView,
mutateIssues,
orderBy,
params,
properties,
showEmptyGroups,
}}
/>
</>
);
};

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import {
ViewAssigneeSelect,
ViewDueDateSelect,
ViewEstimateSelect,
ViewLabelSelect,
ViewIssueLabel,
ViewPrioritySelect,
ViewStateSelect,
} from "components/issues";
@ -53,7 +53,7 @@ type Props = {
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
gridTemplateColumns: string;
isCompleted?: boolean;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
nestingLevel: number;
@ -68,7 +68,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
handleEditIssue,
handleDeleteIssue,
gridTemplateColumns,
isCompleted = false,
disableUserActions,
user,
userAuth,
nestingLevel,
@ -190,7 +190,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
{!isNotAllowed && !isCompleted && (
{!isNotAllowed && !disableUserActions && (
<div className="absolute top-0 left-2.5 opacity-0 group-hover:opacity-100">
<Popover2
isOpen={isOpen}
@ -263,7 +263,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
)}
</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]">
{issue.name}
</a>
@ -311,15 +311,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
)}
{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">
<ViewLabelSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
tooltipPosition={tooltipPosition}
customButton
user={user}
isNotAllowed={isNotAllowed}
/>
<ViewIssueLabel issue={issue} maxRender={1} />
</div>
)}

View File

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

View File

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

View File

@ -110,8 +110,43 @@ export const ActiveCycleDetails: React.FC = () => {
if (!cycle)
return (
<div className="flex w-full items-center justify-start rounded-[10px] bg-custom-background-80 px-6 py-4">
<h3 className="text-base font-medium text-custom-text-100 ">No active cycle is present.</h3>
<div className="h-full grid place-items-center text-center">
<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>
);

View File

@ -9,6 +9,7 @@ import cyclesService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
import useLocalStorage from "hooks/use-local-storage";
// components
import {
CreateUpdateCycleModal,
@ -18,11 +19,7 @@ import {
SingleCycleList,
} from "components/cycles";
// ui
import { EmptyState, Loader } from "components/ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// images
import emptyCycle from "public/empty-state/cycle.svg";
import { Loader } from "components/ui";
// helpers
import { getDateRangeStatus } from "helpers/date-time.helper";
// types
@ -48,6 +45,8 @@ export const CyclesView: React.FC<Props> = ({ cycles, viewType }) => {
const [deleteCycleModal, setDeleteCycleModal] = useState(false);
const [selectedCycleToDelete, setSelectedCycleToDelete] = useState<ICycle | null>(null);
const { storedValue: cycleTab } = useLocalStorage("cycleTab", "All");
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -206,19 +205,48 @@ export const CyclesView: React.FC<Props> = ({ cycles, viewType }) => {
<CyclesListGanttChartView cycles={cycles ?? []} />
)
) : (
<EmptyState
title="Plan your project with cycles"
description="Cycle is a custom time period in which a team works to complete items on their backlog."
image={emptyCycle}
buttonText="New Cycle"
buttonIcon={<PlusIcon className="h-4 w-4" />}
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "q",
});
document.dispatchEvent(e);
}}
/>
<div className="h-full grid place-items-center text-center">
<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">
{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" ? (
<Loader className="space-y-4">

View File

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

View File

@ -29,16 +29,13 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
handleSubmit,
control,
reset,
watch,
} = useForm<ICycle>({
defaultValues,
});
const handleCreateUpdateCycle = async (formData: Partial<ICycle>) => {
await handleFormSubmit(formData);
reset({
...defaultValues,
});
};
useEffect(() => {
@ -48,6 +45,15 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
});
}, [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 (
<form onSubmit={handleSubmit(handleCreateUpdateCycle)}>
<div className="space-y-5">
@ -91,7 +97,13 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
control={control}
name="start_date"
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>
@ -100,7 +112,12 @@ export const CycleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, stat
control={control}
name="end_date"
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>

View File

@ -13,9 +13,9 @@ import useToast from "hooks/use-toast";
// components
import { CycleForm } from "components/cycles";
// helper
import { getDateRangeStatus, isDateGreaterThanToday } from "helpers/date-time.helper";
import { getDateRangeStatus } from "helpers/date-time.helper";
// types
import type { ICurrentUserResponse, ICycle } from "types";
import type { CycleDateCheckData, ICurrentUserResponse, ICycle, IProject } from "types";
// fetch keys
import {
COMPLETED_CYCLES_LIST,
@ -23,6 +23,7 @@ import {
CYCLES_LIST,
DRAFT_CYCLES_LIST,
INCOMPLETE_CYCLES_LIST,
PROJECT_DETAILS,
UPCOMING_CYCLES_LIST,
} from "constants/fetch-keys";
@ -65,7 +66,20 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
}
mutate(INCOMPLETE_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({
type: "success",
@ -121,8 +135,6 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
}
}
handleClose();
setToastAlert({
type: "success",
title: "Success!",
@ -138,19 +150,16 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
});
};
const dateChecker = async (payload: any) => {
try {
const res = await cycleService.cycleDateCheck(
workspaceSlug as string,
projectId as string,
payload
);
console.log(res);
return res.status;
} catch (err) {
console.log(err);
return false;
}
const dateChecker = async (payload: CycleDateCheckData) => {
let status = false;
await cycleService
.cycleDateCheck(workspaceSlug as string, projectId as string, payload)
.then((res) => {
status = res.status;
});
return status;
};
const handleFormSubmit = async (formData: Partial<ICycle>) => {
@ -160,66 +169,34 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
...formData,
};
if (payload.start_date && payload.end_date) {
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;
}
let isDateValid: boolean = true;
if (data?.start_date && data?.end_date) {
const isDateValidForExistingCycle = await dateChecker({
if (payload.start_date && payload.end_date) {
if (data?.start_date && data?.end_date)
isDateValid = await dateChecker({
start_date: payload.start_date,
end_date: payload.end_date,
cycle_id: data.id,
});
if (isDateValidForExistingCycle) {
await updateCycle(data.id, payload);
return;
} 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",
else
isDateValid = await dateChecker({
start_date: payload.start_date,
end_date: payload.end_date,
});
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 (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -81,12 +81,12 @@ export const IssueAttachments = () => {
} uploaded on ${renderLongDateFormat(file.updated_at)}`}
>
<span>
<ExclamationIcon className="h-3 w-3 fill-current text-custom-text-100" />
<ExclamationIcon className="h-3 w-3 fill-current" />
</span>
</Tooltip>
</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>{convertBytesToSize(file.attributes.size)}</span>
</div>
@ -101,7 +101,7 @@ export const IssueAttachments = () => {
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>
</div>
))}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -96,13 +96,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
setCharacterLimit(false);
setIsSubmitting(true);
handleSubmit(handleDescriptionFormSubmit)()
.then(() => {
setIsSubmitting(false);
})
.catch(() => {
setIsSubmitting(false);
});
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting(false));
}}
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"
@ -110,7 +104,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
disabled={!isAllowed}
/>
{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
className={`${
watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
@ -123,52 +117,47 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
)}
</div>
<span>{errors.name ? errors.name.message : null}</span>
<Controller
name="description"
control={control}
render={({ field: { value } }) => {
if (!value && !watch("description_html")) return <></>;
<div className="relative">
<Controller
name="description"
control={control}
render={({ field: { value } }) => {
if (!value && !watch("description_html")) return <></>;
return (
<RemirrorRichTextEditor
value={
!value ||
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
? watch("description_html")
: value
}
onJSONChange={(jsonValue) => {
setShowAlert(true);
setValue("description", jsonValue);
}}
onHTMLChange={(htmlValue) => {
setShowAlert(true);
setValue("description_html", htmlValue);
}}
onBlur={() => {
setIsSubmitting(true);
handleSubmit(handleDescriptionFormSubmit)()
.then(() => {
setIsSubmitting(false);
setShowAlert(false);
})
.catch(() => {
setIsSubmitting(false);
});
}}
placeholder="Description"
editable={isAllowed}
/>
);
}}
/>
<div
className={`absolute -bottom-8 right-0 text-sm text-custom-text-200 ${
isSubmitting ? "block" : "hidden"
}`}
>
Saving...
return (
<RemirrorRichTextEditor
value={
!value ||
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
? watch("description_html")
: value
}
onJSONChange={(jsonValue) => {
setShowAlert(true);
setValue("description", jsonValue);
}}
onHTMLChange={(htmlValue) => {
setShowAlert(true);
setValue("description_html", htmlValue);
}}
onBlur={() => {
setIsSubmitting(true);
handleSubmit(handleDescriptionFormSubmit)()
.then(() => setShowAlert(false))
.finally(() => 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>
</div>
);

View File

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

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