Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6420c1b170 | |||
| e3d489ae52 | |||
| b080b3047c | |||
| 110be4c882 | |||
| 5b22b66064 | |||
| cc2146a404 | |||
| 82d03b4882 | |||
| 7045f80ec6 | |||
| be39b097ab | |||
| fd9d76f15f | |||
| e802790581 | |||
| 53487eaadd | |||
| c65f93c971 | |||
| cc513b9030 | |||
| 624dfdb2b6 | |||
| 6c3be6faba | |||
| 90c819b1ba | |||
| 581dff5482 | |||
| 9a6026c0c7 | |||
| ea605cf48c | |||
| ebc9f36dc2 | |||
| e73b39fcdf | |||
| be96ec47ba | |||
| dcde6b2567 | |||
| 8260e2f897 | |||
| 2cd3dc2f9d | |||
| ecfc09da30 | |||
| 0af49306a9 | |||
| cf4bb02a9f | |||
| 9c0a2b74b3 | |||
| b22e062576 | |||
| f80f98ddef | |||
| 9fd245a53c | |||
| b2451ac03d |
@@ -19,8 +19,6 @@ NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
|
||||
NEXT_PUBLIC_TRACK_EVENTS=0
|
||||
# Slack for Slack Integration
|
||||
NEXT_PUBLIC_SLACK_CLIENT_ID=""
|
||||
# For Telemetry, set it to "app.plane.so"
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
|
||||
|
||||
# Backend
|
||||
# Debug value for api server use it as 0 for production use
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://plane.so">
|
||||
<img src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-readme/plane_logo_.webp" alt="Plane Logo" width="70">
|
||||
<img src="https://res.cloudinary.com/toolspacedev/image/upload/v1680596414/Plane/Plane_Icon_Blue_on_White_150x150_muysa3.jpg" alt="Plane Logo" width="70">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -11,22 +11,22 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.com/invite/A92xrEGCge">
|
||||
<img alt="Discord online members" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
|
||||
<img alt="Discord" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
|
||||
</a>
|
||||
<img alt="Commit activity per month" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
|
||||
<img alt="Discord" 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-readme/plane_screen.webp"
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Screen.png?updatedAt=1684942001069"
|
||||
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-readme/plane_screens_dark_mode.webp"
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Screens_Dark_Mode.png?updatedAt=1684942388044"
|
||||
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-readme/plane_views_dark_mode.webp"
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Views_Dark_Mode.png?updatedAt=1684943050275"
|
||||
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-readme/plane_issue_detail_dark_mode.webp"
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Issue_Detail_Dark_Mode.png?updatedAt=1684943050202"
|
||||
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-readme/plane_cycles_modules_dark_mode.webp"
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Cycles___Modules_Dark_Mode.png?updatedAt=1684943050281"
|
||||
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-readme/plane_analytics_dark_mode.webp"
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Analytics_Dark_Mode.png?updatedAt=1684944596824"
|
||||
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-readme/plane_pages_dark_mode.webp"
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Pages_Dark_Mode.png?updatedAt=1684943050202"
|
||||
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-readme/plane_commad_k_dark_mode.webp"
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Commad_K_Dark_Mode.png?updatedAt=1684943050312"
|
||||
alt="Plane Command Menu"
|
||||
width="100%"
|
||||
/>
|
||||
|
||||
@@ -49,7 +49,7 @@ USER root
|
||||
RUN apk --no-cache add "bash~=5.2"
|
||||
COPY ./bin ./bin/
|
||||
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker
|
||||
RUN chmod -R 777 /code
|
||||
|
||||
USER captain
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
python manage.py wait_for_db
|
||||
celery -A plane beat -l info
|
||||
@@ -6,4 +6,4 @@ python manage.py migrate
|
||||
# Create a Default User
|
||||
python bin/user_script.py
|
||||
|
||||
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import os, sys, random, string
|
||||
import os, sys
|
||||
import uuid
|
||||
|
||||
sys.path.append("/code")
|
||||
@@ -19,9 +19,9 @@ def populate():
|
||||
user = User.objects.create(email=default_email, username=uuid.uuid4().hex)
|
||||
user.set_password(default_password)
|
||||
user.save()
|
||||
print(f"User created with an email: {default_email}")
|
||||
else:
|
||||
print(f"User already exists with the default email: {default_email}")
|
||||
print("User created")
|
||||
|
||||
print("Success")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission, WorkspaceViewerPermission
|
||||
from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission
|
||||
from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission
|
||||
|
||||
@@ -61,13 +61,3 @@ 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()
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
from .base import BaseSerializer
|
||||
from .user import UserSerializer, UserLiteSerializer, ChangePasswordSerializer, ResetPasswordSerializer, UserAdminLiteSerializer
|
||||
from .people import (
|
||||
ChangePasswordSerializer,
|
||||
ResetPasswordSerializer,
|
||||
TokenSerializer,
|
||||
)
|
||||
from .user import UserSerializer, UserLiteSerializer
|
||||
from .workspace import (
|
||||
WorkSpaceSerializer,
|
||||
WorkSpaceMemberSerializer,
|
||||
@@ -7,7 +12,6 @@ from .workspace import (
|
||||
WorkSpaceMemberInviteSerializer,
|
||||
WorkspaceLiteSerializer,
|
||||
WorkspaceThemeSerializer,
|
||||
WorkspaceMemberAdminSerializer,
|
||||
)
|
||||
from .project import (
|
||||
ProjectSerializer,
|
||||
@@ -21,7 +25,7 @@ from .project import (
|
||||
)
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
|
||||
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer
|
||||
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer
|
||||
from .asset import FileAssetSerializer
|
||||
from .issue import (
|
||||
IssueCreateSerializer,
|
||||
@@ -39,8 +43,6 @@ from .issue import (
|
||||
IssueLiteSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueSubscriberSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
|
||||
@@ -12,12 +12,6 @@ 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)
|
||||
|
||||
@@ -29,8 +29,6 @@ from plane.db.models import (
|
||||
ModuleIssue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
)
|
||||
|
||||
|
||||
@@ -52,20 +50,6 @@ 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):
|
||||
@@ -117,15 +101,8 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
labels = validated_data.pop("labels_list", None)
|
||||
blocks = validated_data.pop("blocks_list", None)
|
||||
|
||||
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
|
||||
project = self.context["project"]
|
||||
issue = Issue.objects.create(**validated_data, project=project)
|
||||
|
||||
if blockers is not None and len(blockers):
|
||||
IssueBlocker.objects.bulk_create(
|
||||
@@ -133,10 +110,10 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
IssueBlocker(
|
||||
block=issue,
|
||||
blocked_by=blocker,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
created_by=issue.created_by,
|
||||
updated_by=issue.updated_by,
|
||||
)
|
||||
for blocker in blockers
|
||||
],
|
||||
@@ -149,10 +126,10 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
created_by=issue.created_by,
|
||||
updated_by=issue.updated_by,
|
||||
)
|
||||
for user in assignees
|
||||
],
|
||||
@@ -160,14 +137,14 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
)
|
||||
else:
|
||||
# Then assign it to default assignee
|
||||
if default_assignee_id is not None:
|
||||
if project.default_assignee is not None:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
assignee=project.default_assignee,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
created_by=issue.created_by,
|
||||
updated_by=issue.updated_by,
|
||||
)
|
||||
|
||||
if labels is not None and len(labels):
|
||||
@@ -176,10 +153,10 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
created_by=issue.created_by,
|
||||
updated_by=issue.updated_by,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
@@ -192,10 +169,10 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
IssueBlocker(
|
||||
block=block,
|
||||
blocked_by=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
created_by=issue.created_by,
|
||||
updated_by=issue.updated_by,
|
||||
)
|
||||
for block in blocks
|
||||
],
|
||||
@@ -210,12 +187,6 @@ 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(
|
||||
@@ -223,10 +194,10 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
IssueBlocker(
|
||||
block=instance,
|
||||
blocked_by=blocker,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
project=instance.project,
|
||||
workspace=instance.project.workspace,
|
||||
created_by=instance.created_by,
|
||||
updated_by=instance.updated_by,
|
||||
)
|
||||
for blocker in blockers
|
||||
],
|
||||
@@ -240,10 +211,10 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
project=instance.project,
|
||||
workspace=instance.project.workspace,
|
||||
created_by=instance.created_by,
|
||||
updated_by=instance.updated_by,
|
||||
)
|
||||
for user in assignees
|
||||
],
|
||||
@@ -257,10 +228,10 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
project=instance.project,
|
||||
workspace=instance.project.workspace,
|
||||
created_by=instance.created_by,
|
||||
updated_by=instance.updated_by,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
@@ -274,25 +245,23 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
IssueBlocker(
|
||||
block=block,
|
||||
blocked_by=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
project=instance.project,
|
||||
workspace=instance.project.workspace,
|
||||
created_by=instance.created_by,
|
||||
updated_by=instance.updated_by,
|
||||
)
|
||||
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")
|
||||
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
|
||||
class Meta:
|
||||
model = IssueActivity
|
||||
@@ -366,31 +335,19 @@ class IssueLabelSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class BlockedIssueSerializer(BaseSerializer):
|
||||
blocked_issue_detail = IssueProjectLiteSerializer(source="block", read_only=True)
|
||||
blocked_issue_detail = IssueFlatSerializer(source="block", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueBlocker
|
||||
fields = [
|
||||
"blocked_issue_detail",
|
||||
"blocked_by",
|
||||
"block",
|
||||
]
|
||||
read_only_fields = fields
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BlockerIssueSerializer(BaseSerializer):
|
||||
blocker_issue_detail = IssueProjectLiteSerializer(
|
||||
source="blocked_by", read_only=True
|
||||
)
|
||||
blocker_issue_detail = IssueFlatSerializer(source="blocked_by", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IssueBlocker
|
||||
fields = [
|
||||
"blocker_issue_detail",
|
||||
"blocked_by",
|
||||
"block",
|
||||
]
|
||||
read_only_fields = fields
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class IssueAssigneeSerializer(BaseSerializer):
|
||||
@@ -503,89 +460,6 @@ 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)
|
||||
@@ -605,7 +479,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 = IssueStateFlatSerializer(read_only=True, source="parent")
|
||||
parent_detail = IssueFlatSerializer(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
|
||||
@@ -617,7 +491,6 @@ 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
|
||||
@@ -643,7 +516,6 @@ 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
|
||||
|
||||
@@ -3,7 +3,7 @@ from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .issue import IssueFlatSerializer, LabelLiteSerializer
|
||||
from .issue import IssueFlatSerializer, LabelSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from plane.db.models import Page, PageBlock, PageFavorite, PageLabel, Label
|
||||
@@ -23,22 +23,16 @@ 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 = LabelLiteSerializer(read_only=True, source="labels", many=True)
|
||||
label_details = LabelSerializer(read_only=True, source="labels", many=True)
|
||||
labels_list = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
blocks = PageBlockLiteSerializer(read_only=True, many=True)
|
||||
blocks = PageBlockSerializer(read_only=True, many=True)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from rest_framework.serializers import (
|
||||
ModelSerializer,
|
||||
Serializer,
|
||||
CharField,
|
||||
SerializerMethodField,
|
||||
)
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
class UserSerializer(ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = "__all__"
|
||||
extra_kwargs = {"password": {"write_only": True}}
|
||||
|
||||
|
||||
class ChangePasswordSerializer(Serializer):
|
||||
model = User
|
||||
|
||||
"""
|
||||
Serializer for password change endpoint.
|
||||
"""
|
||||
old_password = CharField(required=True)
|
||||
new_password = CharField(required=True)
|
||||
|
||||
|
||||
class ResetPasswordSerializer(Serializer):
|
||||
model = User
|
||||
|
||||
"""
|
||||
Serializer for password change endpoint.
|
||||
"""
|
||||
new_password = CharField(required=True)
|
||||
confirm_password = CharField(required=True)
|
||||
|
||||
|
||||
class TokenSerializer(ModelSerializer):
|
||||
|
||||
user = UserSerializer()
|
||||
access_token = SerializerMethodField()
|
||||
refresh_token = SerializerMethodField()
|
||||
|
||||
def get_access_token(self, obj):
|
||||
refresh_token = RefreshToken.for_user(obj.user)
|
||||
return str(refresh_token.access_token)
|
||||
|
||||
def get_refresh_token(self, obj):
|
||||
refresh_token = RefreshToken.for_user(obj.user)
|
||||
return str(refresh_token)
|
||||
|
||||
class Meta:
|
||||
model = Token
|
||||
fields = "__all__"
|
||||
@@ -7,7 +7,7 @@ from rest_framework import serializers
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.api.serializers.workspace import WorkSpaceSerializer, WorkspaceLiteSerializer
|
||||
from plane.api.serializers.user import UserLiteSerializer, UserAdminLiteSerializer
|
||||
from plane.api.serializers.user import UserLiteSerializer
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
@@ -93,7 +93,6 @@ 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
|
||||
@@ -110,17 +109,6 @@ class ProjectMemberSerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class ProjectMemberAdminSerializer(BaseSerializer):
|
||||
workspace = WorkspaceLiteSerializer(read_only=True)
|
||||
project = ProjectLiteSerializer(read_only=True)
|
||||
member = UserAdminLiteSerializer(read_only=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class ProjectMemberInviteSerializer(BaseSerializer):
|
||||
project = ProjectLiteSerializer(read_only=True)
|
||||
workspace = WorkspaceLiteSerializer(read_only=True)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module import
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import User
|
||||
@@ -40,50 +37,11 @@ class UserLiteSerializer(BaseSerializer):
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"avatar",
|
||||
"is_bot",
|
||||
"display_name",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"is_bot",
|
||||
]
|
||||
|
||||
|
||||
class UserAdminLiteSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"avatar",
|
||||
"is_bot",
|
||||
"display_name",
|
||||
"email",
|
||||
"avatar",
|
||||
"is_bot",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"is_bot",
|
||||
]
|
||||
|
||||
|
||||
class ChangePasswordSerializer(serializers.Serializer):
|
||||
model = User
|
||||
|
||||
"""
|
||||
Serializer for password change endpoint.
|
||||
"""
|
||||
old_password = serializers.CharField(required=True)
|
||||
new_password = serializers.CharField(required=True)
|
||||
|
||||
|
||||
class ResetPasswordSerializer(serializers.Serializer):
|
||||
model = User
|
||||
|
||||
"""
|
||||
Serializer for password change endpoint.
|
||||
"""
|
||||
new_password = serializers.CharField(required=True)
|
||||
confirm_password = serializers.CharField(required=True)
|
||||
|
||||
@@ -3,7 +3,7 @@ from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .user import UserLiteSerializer, UserAdminLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
|
||||
from plane.db.models import (
|
||||
User,
|
||||
@@ -33,30 +33,10 @@ class WorkSpaceSerializer(BaseSerializer):
|
||||
"owner",
|
||||
]
|
||||
|
||||
class WorkspaceLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Workspace
|
||||
fields = [
|
||||
"name",
|
||||
"slug",
|
||||
"id",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
|
||||
class WorkSpaceMemberSerializer(BaseSerializer):
|
||||
member = UserLiteSerializer(read_only=True)
|
||||
workspace = WorkspaceLiteSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceMember
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class WorkspaceMemberAdminSerializer(BaseSerializer):
|
||||
member = UserAdminLiteSerializer(read_only=True)
|
||||
workspace = WorkspaceLiteSerializer(read_only=True)
|
||||
workspace = WorkSpaceSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceMember
|
||||
@@ -121,6 +101,17 @@ class TeamSerializer(BaseSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class WorkspaceLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Workspace
|
||||
fields = [
|
||||
"name",
|
||||
"slug",
|
||||
"id",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class WorkspaceThemeSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = WorkspaceTheme
|
||||
|
||||
@@ -32,7 +32,6 @@ from plane.api.views import (
|
||||
InviteWorkspaceEndpoint,
|
||||
JoinWorkspaceEndpoint,
|
||||
WorkSpaceMemberViewSet,
|
||||
WorkspaceMembersEndpoint,
|
||||
WorkspaceInvitationsViewset,
|
||||
UserWorkspaceInvitationsEndpoint,
|
||||
WorkspaceMemberUserEndpoint,
|
||||
@@ -46,11 +45,6 @@ from plane.api.views import (
|
||||
UserIssueCompletedGraphEndpoint,
|
||||
UserWorkspaceDashboardEndpoint,
|
||||
WorkspaceThemeViewSet,
|
||||
WorkspaceUserProfileStatsEndpoint,
|
||||
WorkspaceUserActivityEndpoint,
|
||||
WorkspaceUserProfileEndpoint,
|
||||
WorkspaceUserProfileIssuesEndpoint,
|
||||
WorkspaceLabelsEndpoint,
|
||||
## End Workspaces
|
||||
# File Assets
|
||||
FileAssetEndpoint,
|
||||
@@ -60,7 +54,6 @@ from plane.api.views import (
|
||||
ProjectViewSet,
|
||||
InviteProjectEndpoint,
|
||||
ProjectMemberViewSet,
|
||||
ProjectMemberEndpoint,
|
||||
ProjectMemberInvitationsViewset,
|
||||
ProjectMemberUserEndpoint,
|
||||
AddMemberToProjectEndpoint,
|
||||
@@ -86,9 +79,6 @@ from plane.api.views import (
|
||||
IssueAttachmentEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
IssueReactionViewSet,
|
||||
CommentReactionViewSet,
|
||||
ExportIssuesEndpoint,
|
||||
## End Issues
|
||||
# States
|
||||
StateViewSet,
|
||||
@@ -337,11 +327,6 @@ urlpatterns = [
|
||||
),
|
||||
name="workspace",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/workspace-members/",
|
||||
WorkspaceMembersEndpoint.as_view(),
|
||||
name="workspace-members",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/teams/",
|
||||
TeamMemberViewSet.as_view(
|
||||
@@ -400,31 +385,6 @@ 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(
|
||||
@@ -475,11 +435,6 @@ urlpatterns = [
|
||||
),
|
||||
name="project",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
|
||||
ProjectMemberEndpoint.as_view(),
|
||||
name="project",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/members/add/",
|
||||
AddMemberToProjectEndpoint.as_view(),
|
||||
@@ -821,11 +776,6 @@ urlpatterns = [
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/export-issues/",
|
||||
ExportIssuesEndpoint.as_view(),
|
||||
name="export-issues",
|
||||
),
|
||||
## End Issues
|
||||
## Issue Activity
|
||||
path(
|
||||
@@ -886,48 +836,6 @@ 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/",
|
||||
@@ -1332,7 +1240,7 @@ urlpatterns = [
|
||||
## End Importer
|
||||
# Search
|
||||
path(
|
||||
"workspaces/<str:slug>/search/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/search/",
|
||||
GlobalSearchEndpoint.as_view(),
|
||||
name="global-search",
|
||||
),
|
||||
|
||||
@@ -12,9 +12,8 @@ from .project import (
|
||||
ProjectUserViewsEndpoint,
|
||||
ProjectMemberUserEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
ProjectMemberEndpoint,
|
||||
)
|
||||
from .user import (
|
||||
from .people import (
|
||||
UserEndpoint,
|
||||
UpdateUserOnBoardedEndpoint,
|
||||
UpdateUserTourCompletedEndpoint,
|
||||
@@ -43,12 +42,6 @@ from .workspace import (
|
||||
UserIssueCompletedGraphEndpoint,
|
||||
UserWorkspaceDashboardEndpoint,
|
||||
WorkspaceThemeViewSet,
|
||||
WorkspaceUserProfileStatsEndpoint,
|
||||
WorkspaceUserActivityEndpoint,
|
||||
WorkspaceUserProfileEndpoint,
|
||||
WorkspaceUserProfileIssuesEndpoint,
|
||||
WorkspaceLabelsEndpoint,
|
||||
WorkspaceMembersEndpoint,
|
||||
)
|
||||
from .state import StateViewSet
|
||||
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
|
||||
@@ -75,9 +68,6 @@ from .issue import (
|
||||
IssueAttachmentEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
CommentReactionViewSet,
|
||||
IssueReactionViewSet,
|
||||
ExportIssuesEndpoint
|
||||
)
|
||||
|
||||
from .auth_extended import (
|
||||
|
||||
@@ -79,12 +79,12 @@ class AnalyticsEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
assignee_details = {}
|
||||
if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
|
||||
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
|
||||
assignee_details = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
.order_by("assignees__id")
|
||||
.distinct("assignees__id")
|
||||
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name", "assignees__id")
|
||||
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
|
||||
)
|
||||
|
||||
|
||||
@@ -243,21 +243,21 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
|
||||
)
|
||||
most_issue_created_user = (
|
||||
queryset.exclude(created_by=None)
|
||||
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__display_name")
|
||||
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__email")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)[:5]
|
||||
|
||||
most_issue_closed_user = (
|
||||
queryset.filter(completed_at__isnull=False, assignees__isnull=False)
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name")
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)[:5]
|
||||
|
||||
pending_issue_user = (
|
||||
queryset.filter(completed_at__isnull=True)
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name")
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ from sentry_sdk import capture_exception
|
||||
|
||||
## Module imports
|
||||
from . import BaseAPIView
|
||||
from plane.api.serializers import (
|
||||
from plane.api.serializers.people import (
|
||||
ChangePasswordSerializer,
|
||||
ResetPasswordSerializer,
|
||||
)
|
||||
|
||||
@@ -279,8 +279,6 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Clean up
|
||||
email = email.strip().lower()
|
||||
validate_email(email)
|
||||
|
||||
## Generate a random token
|
||||
@@ -348,7 +346,7 @@ class MagicSignInEndpoint(BaseAPIView):
|
||||
def post(self, request):
|
||||
try:
|
||||
user_token = request.data.get("token", "").strip()
|
||||
key = request.data.get("key", False).strip().lower()
|
||||
key = request.data.get("key", False)
|
||||
|
||||
if not key or user_token == "":
|
||||
return Response(
|
||||
|
||||
@@ -31,7 +31,6 @@ from plane.api.serializers import (
|
||||
CycleIssueSerializer,
|
||||
CycleFavoriteSerializer,
|
||||
IssueStateSerializer,
|
||||
CycleWriteSerializer,
|
||||
)
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
@@ -339,7 +338,7 @@ class CycleViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True)
|
||||
serializer = CycleSerializer(cycle, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -692,6 +691,7 @@ 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,
|
||||
}
|
||||
)
|
||||
@@ -706,6 +706,9 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class CycleFavoriteViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = CycleFavoriteSerializer
|
||||
model = CycleFavorite
|
||||
|
||||
@@ -30,6 +30,31 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
count = 0
|
||||
|
||||
# If logger is enabled check for request limit
|
||||
if settings.LOGGER_BASE_URL:
|
||||
try:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
settings.LOGGER_BASE_URL,
|
||||
json={"user_id": str(request.user.id)},
|
||||
headers=headers,
|
||||
)
|
||||
count = response.json().get("count", 0)
|
||||
if not response.json().get("success", False):
|
||||
return Response(
|
||||
{
|
||||
"error": "You have surpassed the monthly limit for AI assistance"
|
||||
},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
||||
prompt = request.data.get("prompt", False)
|
||||
task = request.data.get("task", False)
|
||||
|
||||
@@ -57,6 +82,7 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
{
|
||||
"response": text,
|
||||
"response_html": text_html,
|
||||
"count": count,
|
||||
"project_detail": ProjectLiteSerializer(project).data,
|
||||
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
|
||||
},
|
||||
|
||||
@@ -332,7 +332,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
# if there is no default state assign any random state
|
||||
if default_state is None:
|
||||
default_state = State.objects.filter(
|
||||
~Q(name="Triage"), project_id=project_id
|
||||
~Q(name="Triage"), sproject_id=project_id
|
||||
).first()
|
||||
|
||||
# Get the maximum sequence_id
|
||||
@@ -458,7 +458,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
actor=request.user,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
comment=f"imported the issue from {service}",
|
||||
comment=f"{request.user.email} importer the issue from {service}",
|
||||
verb="created",
|
||||
created_by=request.user,
|
||||
)
|
||||
|
||||
@@ -46,11 +46,8 @@ from plane.api.serializers import (
|
||||
IssueAttachmentSerializer,
|
||||
IssueSubscriberSerializer,
|
||||
ProjectMemberLiteSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
)
|
||||
from plane.api.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
ProjectEntityPermission,
|
||||
WorkSpaceAdminPermission,
|
||||
ProjectMemberPermission,
|
||||
@@ -68,13 +65,10 @@ 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
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.bgtasks.project_issue_export import issue_export_task
|
||||
|
||||
|
||||
class IssueViewSet(BaseViewSet):
|
||||
@@ -157,19 +151,13 @@ 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")
|
||||
print(filters)
|
||||
show_sub_issues = request.GET.get("show_sub_issues", "true")
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", None]
|
||||
@@ -256,6 +244,12 @@ 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
|
||||
@@ -268,7 +262,7 @@ class IssueViewSet(BaseViewSet):
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
print(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -276,15 +270,9 @@ class IssueViewSet(BaseViewSet):
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
project = Project.objects.get(workspace__slug=slug, pk=project_id)
|
||||
serializer = IssueCreateSerializer(
|
||||
data=request.data,
|
||||
context={
|
||||
"project_id": project_id,
|
||||
"workspace_id": project.workspace_id,
|
||||
"default_assignee_id": project.default_assignee_id,
|
||||
},
|
||||
data=request.data, context={"project": project}
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
@@ -323,17 +311,9 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
@method_decorator(gzip_page)
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
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 = (
|
||||
issues = (
|
||||
Issue.issue_objects.filter(
|
||||
(Q(assignees__in=[request.user]) | Q(created_by=request.user)),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user], workspace__slug=slug
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
@@ -347,7 +327,7 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.order_by(order_by_param)
|
||||
.order_by("-created_at")
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -362,77 +342,9 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(**filters)
|
||||
)
|
||||
|
||||
# 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)
|
||||
serializer = IssueLiteSerializer(issues, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
@@ -478,13 +390,12 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
~Q(field="comment"),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
)
|
||||
.select_related("actor", "workspace", "issue", "project")
|
||||
.select_related("actor", "workspace")
|
||||
).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
|
||||
@@ -507,7 +418,7 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
serializer_class = IssueCommentSerializer
|
||||
model = IssueComment
|
||||
permission_classes = [
|
||||
ProjectLitePermission,
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
@@ -717,7 +628,9 @@ 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)
|
||||
Issue.issue_objects.filter(
|
||||
parent_id=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
@@ -747,7 +660,9 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
state_distribution = (
|
||||
State.objects.filter(~Q(name="Triage"), workspace__slug=slug)
|
||||
State.objects.filter(
|
||||
~Q(name="Triage"), workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
.annotate(
|
||||
state_count=Count(
|
||||
"state_issue",
|
||||
@@ -1181,8 +1096,7 @@ 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(
|
||||
@@ -1190,7 +1104,6 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class IssueSubscriberViewSet(BaseViewSet):
|
||||
serializer_class = IssueSubscriberSerializer
|
||||
model = IssueSubscriber
|
||||
@@ -1231,22 +1144,18 @@ 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:
|
||||
@@ -1346,130 +1255,3 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
class ExportIssuesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug):
|
||||
try:
|
||||
|
||||
issue_export_task.delay(
|
||||
email=request.user.email, data=request.data, slug=slug ,exporter_name=request.user.first_name
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"
|
||||
},
|
||||
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,
|
||||
)
|
||||
@@ -480,6 +480,9 @@ class ModuleLinkViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class ModuleFavoriteViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = ModuleFavoriteSerializer
|
||||
model = ModuleFavorite
|
||||
|
||||
@@ -6,15 +6,14 @@ 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, WorkspaceMember
|
||||
from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue
|
||||
from plane.api.serializers import NotificationSerializer
|
||||
|
||||
|
||||
class NotificationViewSet(BaseViewSet, BasePaginator):
|
||||
class NotificationViewSet(BaseViewSet):
|
||||
model = Notification
|
||||
serializer_class = NotificationSerializer
|
||||
|
||||
@@ -31,20 +30,17 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
||||
|
||||
def list(self, request, slug):
|
||||
try:
|
||||
order_by = request.GET.get("order_by", "-created_at")
|
||||
snoozed = request.GET.get("snoozed", "false")
|
||||
archived = request.GET.get("archived", "false")
|
||||
read = request.GET.get("read", "true")
|
||||
read = request.GET.get("read", "false")
|
||||
|
||||
# Filter type
|
||||
type = request.GET.get("type", "all")
|
||||
|
||||
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")
|
||||
)
|
||||
notifications = Notification.objects.filter(
|
||||
workspace__slug=slug, receiver_id=request.user.id
|
||||
).order_by(order_by)
|
||||
|
||||
# Filter for snoozed notifications
|
||||
if snoozed == "false":
|
||||
@@ -57,6 +53,8 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
||||
Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False)
|
||||
)
|
||||
|
||||
if read == "true":
|
||||
notifications = notifications.filter(read_at__isnull=False)
|
||||
if read == "false":
|
||||
notifications = notifications.filter(read_at__isnull=True)
|
||||
|
||||
@@ -83,23 +81,10 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
||||
|
||||
# Created issues
|
||||
if type == "created":
|
||||
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,
|
||||
)
|
||||
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)
|
||||
|
||||
serializer = NotificationSerializer(notifications, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -228,11 +213,10 @@ class UnreadNotificationEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
# Watching Issues Count
|
||||
watching_issues_count = Notification.objects.filter(
|
||||
watching_notification_count = Notification.objects.filter(
|
||||
workspace__slug=slug,
|
||||
receiver_id=request.user.id,
|
||||
read_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
entity_identifier__in=IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug, subscriber_id=request.user.id
|
||||
).values_list("issue_id", flat=True),
|
||||
@@ -243,7 +227,6 @@ class UnreadNotificationEndpoint(BaseAPIView):
|
||||
workspace__slug=slug,
|
||||
receiver_id=request.user.id,
|
||||
read_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
entity_identifier__in=IssueAssignee.objects.filter(
|
||||
workspace__slug=slug, assignee_id=request.user.id
|
||||
).values_list("issue_id", flat=True),
|
||||
@@ -254,7 +237,6 @@ class UnreadNotificationEndpoint(BaseAPIView):
|
||||
workspace__slug=slug,
|
||||
receiver_id=request.user.id,
|
||||
read_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
entity_identifier__in=Issue.objects.filter(
|
||||
workspace__slug=slug, created_by=request.user
|
||||
).values_list("pk", flat=True),
|
||||
@@ -262,7 +244,7 @@ class UnreadNotificationEndpoint(BaseAPIView):
|
||||
|
||||
return Response(
|
||||
{
|
||||
"watching_issues": watching_issues_count,
|
||||
"watching_notifications": watching_notification_count,
|
||||
"my_issues": my_issues_count,
|
||||
"created_issues": created_issues_count,
|
||||
},
|
||||
|
||||
@@ -301,7 +301,7 @@ class CreateIssueFromPageBlockEndpoint(BaseAPIView):
|
||||
issue=issue,
|
||||
actor=request.user,
|
||||
project_id=project_id,
|
||||
comment=f"created the issue from {page_block.name} block",
|
||||
comment=f"{request.user.email} created the issue from {page_block.name} block",
|
||||
verb="created",
|
||||
)
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
|
||||
def get(self, request):
|
||||
try:
|
||||
queryset = IssueActivity.objects.filter(actor=request.user).select_related(
|
||||
"actor", "workspace", "issue", "project"
|
||||
"actor", "workspace"
|
||||
)
|
||||
|
||||
return self.paginate(
|
||||
@@ -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, Min, Subquery
|
||||
from django.db.models import Q, Exists, OuterRef, Func, F
|
||||
from django.core.validators import validate_email
|
||||
from django.conf import settings
|
||||
|
||||
@@ -25,7 +25,7 @@ from plane.api.serializers import (
|
||||
ProjectFavoriteSerializer,
|
||||
)
|
||||
|
||||
from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission
|
||||
from plane.api.permissions import ProjectBasePermission
|
||||
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
@@ -91,24 +91,6 @@ 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()
|
||||
)
|
||||
|
||||
@@ -120,16 +102,10 @@ 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))
|
||||
.annotate(sort_order=Subquery(sort_order_query))
|
||||
.order_by("sort_order", "name")
|
||||
.order_by("-is_favorite", "name")
|
||||
.annotate(
|
||||
total_members=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("id")
|
||||
@@ -176,47 +152,40 @@ class ProjectViewSet(BaseViewSet):
|
||||
serializer.save()
|
||||
|
||||
# Add the user as Administrator to the project
|
||||
project_member = ProjectMember.objects.create(
|
||||
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 = [
|
||||
{
|
||||
"name": "Backlog",
|
||||
"color": "#A3A3A3",
|
||||
"color": "#5e6ad2",
|
||||
"sequence": 15000,
|
||||
"group": "backlog",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"name": "Todo",
|
||||
"color": "#3A3A3A",
|
||||
"color": "#eb5757",
|
||||
"sequence": 25000,
|
||||
"group": "unstarted",
|
||||
},
|
||||
{
|
||||
"name": "In Progress",
|
||||
"color": "#F59E0B",
|
||||
"color": "#26b5ce",
|
||||
"sequence": 35000,
|
||||
"group": "started",
|
||||
},
|
||||
{
|
||||
"name": "Done",
|
||||
"color": "#16A34A",
|
||||
"color": "#f2c94c",
|
||||
"sequence": 45000,
|
||||
"group": "completed",
|
||||
},
|
||||
{
|
||||
"name": "Cancelled",
|
||||
"color": "#EF4444",
|
||||
"color": "#4cb782",
|
||||
"sequence": 55000,
|
||||
"group": "cancelled",
|
||||
},
|
||||
@@ -238,11 +207,9 @@ class ProjectViewSet(BaseViewSet):
|
||||
]
|
||||
)
|
||||
|
||||
data = serializer.data
|
||||
data["sort_order"] = project_member.sort_order
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(
|
||||
serializer.errors,
|
||||
[serializer.errors[error][0] for error in serializer.errors],
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except IntegrityError as e:
|
||||
@@ -458,7 +425,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
"member__display_name",
|
||||
"member__email",
|
||||
"member__first_name",
|
||||
]
|
||||
|
||||
@@ -600,36 +567,17 @@ class AddMemberToProjectEndpoint(BaseAPIView):
|
||||
{"error": "Atleast one member is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
bulk_project_members = []
|
||||
|
||||
project_members = (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member_id__in=[member.get("member_id") for member in members],
|
||||
)
|
||||
.values("member_id", "sort_order")
|
||||
.order_by("sort_order")
|
||||
)
|
||||
|
||||
for member in members:
|
||||
sort_order = [
|
||||
project_member.get("sort_order")
|
||||
for project_member in project_members
|
||||
if str(project_member.get("member_id"))
|
||||
== str(member.get("member_id"))
|
||||
]
|
||||
bulk_project_members.append(
|
||||
project_members = ProjectMember.objects.bulk_create(
|
||||
[
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
project_members = ProjectMember.objects.bulk_create(
|
||||
bulk_project_members,
|
||||
for member in members
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
@@ -871,15 +819,11 @@ 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()
|
||||
|
||||
@@ -984,23 +928,3 @@ class ProjectFavoritesViewSet(BaseViewSet):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ProjectMemberEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
project_members = ProjectMember.objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).select_related("project", "member")
|
||||
serializer = ProjectMemberSerializer(project_members, many=True)
|
||||
return Response(serializer.data, 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,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
also show related workspace if found
|
||||
"""
|
||||
|
||||
def filter_workspaces(self, query, slug, project_id, workspace_search):
|
||||
def filter_workspaces(self, query, slug, project_id):
|
||||
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, workspace_search):
|
||||
fields = ["name", "identifier"]
|
||||
def filter_projects(self, query, slug, project_id):
|
||||
fields = ["name"]
|
||||
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, workspace_search):
|
||||
fields = ["name", "sequence_id", "project__identifier"]
|
||||
def filter_issues(self, query, slug, project_id):
|
||||
fields = ["name", "sequence_id"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
if field == "sequence_id":
|
||||
@@ -56,123 +56,111 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
q |= Q(**{"sequence_id": sequence_id})
|
||||
else:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
issues = Issue.issue_objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
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",
|
||||
)
|
||||
)
|
||||
|
||||
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):
|
||||
def filter_cycles(self, query, slug, project_id):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
cycles = Cycle.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
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",
|
||||
)
|
||||
)
|
||||
|
||||
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):
|
||||
def filter_modules(self, query, slug, project_id):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
modules = Module.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
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",
|
||||
)
|
||||
)
|
||||
|
||||
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):
|
||||
def filter_pages(self, query, slug, project_id):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
pages = Page.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
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",
|
||||
)
|
||||
)
|
||||
|
||||
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):
|
||||
def filter_views(self, query, slug, project_id):
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
issue_views = IssueView.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
workspace__slug=slug,
|
||||
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",
|
||||
)
|
||||
)
|
||||
|
||||
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):
|
||||
def get(self, request, slug, project_id):
|
||||
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(
|
||||
{
|
||||
@@ -203,7 +191,7 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
|
||||
for model in MODELS_MAPPER.keys():
|
||||
func = MODELS_MAPPER.get(model, None)
|
||||
results[model] = func(query, slug, project_id, workspace_search)
|
||||
results[model] = func(query, slug, project_id)
|
||||
return Response({"results": results}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
@@ -218,7 +206,6 @@ 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")
|
||||
@@ -229,12 +216,10 @@ 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)
|
||||
|
||||
@@ -266,12 +251,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",
|
||||
|
||||
@@ -13,18 +13,12 @@ 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 (
|
||||
Prefetch,
|
||||
CharField,
|
||||
Count,
|
||||
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
|
||||
@@ -45,9 +39,6 @@ from plane.api.serializers import (
|
||||
UserLiteSerializer,
|
||||
ProjectMemberSerializer,
|
||||
WorkspaceThemeSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueLiteSerializer,
|
||||
WorkspaceMemberAdminSerializer
|
||||
)
|
||||
from plane.api.views.base import BaseAPIView
|
||||
from . import BaseViewSet
|
||||
@@ -69,24 +60,9 @@ from plane.db.models import (
|
||||
PageFavorite,
|
||||
Page,
|
||||
IssueViewFavorite,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
Label,
|
||||
WorkspaceMember,
|
||||
CycleIssue,
|
||||
IssueReaction,
|
||||
)
|
||||
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):
|
||||
@@ -538,7 +514,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
|
||||
|
||||
|
||||
class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
serializer_class = WorkspaceMemberAdminSerializer
|
||||
serializer_class = WorkSpaceMemberSerializer
|
||||
model = WorkspaceMember
|
||||
|
||||
permission_classes = [
|
||||
@@ -546,7 +522,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
"member__display_name",
|
||||
"member__email",
|
||||
"member__first_name",
|
||||
]
|
||||
|
||||
@@ -621,19 +597,6 @@ 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
|
||||
@@ -691,7 +654,7 @@ class TeamMemberViewSet(BaseViewSet):
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
"member__display_name",
|
||||
"member__email",
|
||||
"member__first_name",
|
||||
]
|
||||
|
||||
@@ -1046,422 +1009,3 @@ 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,
|
||||
"display_name": user_data.display_name,
|
||||
},
|
||||
},
|
||||
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")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.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,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceMembersEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
workspace_members = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug
|
||||
).select_related("workspace", "member")
|
||||
serialzier = WorkSpaceMemberSerializer(workspace_members, many=True)
|
||||
return Response(serialzier.data, 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,
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ row_mapping = {
|
||||
"state__name": "State",
|
||||
"state__group": "State Group",
|
||||
"labels__name": "Label",
|
||||
"assignees__display_name": "Assignee Name",
|
||||
"assignees__email": "Assignee Name",
|
||||
"start_date": "Start Date",
|
||||
"target_date": "Due Date",
|
||||
"completed_at": "Completed At",
|
||||
@@ -51,12 +51,12 @@ def analytic_export_task(email, data, slug):
|
||||
segmented = segment
|
||||
|
||||
assignee_details = {}
|
||||
if x_axis in ["assignees__display_name"] or segment in ["assignees__display_name"]:
|
||||
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
|
||||
assignee_details = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
.order_by("assignees__id")
|
||||
.distinct("assignees__id")
|
||||
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name")
|
||||
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
|
||||
)
|
||||
|
||||
if segment:
|
||||
@@ -93,17 +93,17 @@ def analytic_export_task(email, data, slug):
|
||||
else:
|
||||
generated_row.append("0")
|
||||
# x-axis replacement for names
|
||||
if x_axis in ["assignees__display_name"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)]
|
||||
if x_axis in ["assignees__email"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
|
||||
if len(assignee):
|
||||
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
rows.append(tuple(generated_row))
|
||||
|
||||
# If segment is ["assignees__display_name"] then replace segment_zero rows with first and last names
|
||||
if segmented in ["assignees__display_name"]:
|
||||
# If segment is ["assignees__email"] then replace segment_zero rows with first and last names
|
||||
if segmented in ["assignees__email"]:
|
||||
for index, segm in enumerate(row_zero[2:]):
|
||||
# find the name of the user
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(segm)]
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(segm)]
|
||||
if len(assignee):
|
||||
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
|
||||
@@ -141,8 +141,8 @@ def analytic_export_task(email, data, slug):
|
||||
else distribution.get(item)[0].get("estimate "),
|
||||
]
|
||||
# x-axis replacement to names
|
||||
if x_axis in ["assignees__display_name"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)]
|
||||
if x_axis in ["assignees__email"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
|
||||
if len(assignee):
|
||||
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ def track_name(
|
||||
field="name",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the name to {requested_data.get('name')}",
|
||||
comment=f"{actor.email} updated the name to {requested_data.get('name')}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -70,12 +70,12 @@ def track_parent(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}",
|
||||
old_value=f"{project.identifier}-{old_parent.sequence_id}",
|
||||
new_value=None,
|
||||
field="parent",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the parent issue to None",
|
||||
comment=f"{actor.email} updated the parent issue to None",
|
||||
old_identifier=old_parent.id,
|
||||
new_identifier=None,
|
||||
)
|
||||
@@ -88,14 +88,14 @@ def track_parent(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}"
|
||||
old_value=f"{project.identifier}-{old_parent.sequence_id}"
|
||||
if old_parent is not None
|
||||
else None,
|
||||
new_value=f"{new_parent.project.identifier}-{new_parent.sequence_id}",
|
||||
new_value=f"{project.identifier}-{new_parent.sequence_id}",
|
||||
field="parent",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the parent issue to {new_parent.name}",
|
||||
comment=f"{actor.email} updated the parent issue to {new_parent.name}",
|
||||
old_identifier=old_parent.id if old_parent is not None else None,
|
||||
new_identifier=new_parent.id,
|
||||
)
|
||||
@@ -123,7 +123,7 @@ def track_priority(
|
||||
field="priority",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the priority to None",
|
||||
comment=f"{actor.email} updated the priority to None",
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -137,7 +137,7 @@ def track_priority(
|
||||
field="priority",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the priority to {requested_data.get('priority')}",
|
||||
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -165,7 +165,7 @@ def track_state(
|
||||
field="state",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the state to {new_state.name}",
|
||||
comment=f"{actor.email} updated the state to {new_state.name}",
|
||||
old_identifier=old_state.id,
|
||||
new_identifier=new_state.id,
|
||||
)
|
||||
@@ -194,7 +194,7 @@ def track_description(
|
||||
field="description",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the description to {requested_data.get('description_html')}",
|
||||
comment=f"{actor.email} updated the description to {requested_data.get('description_html')}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -220,7 +220,7 @@ def track_target_date(
|
||||
field="target_date",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the target date to None",
|
||||
comment=f"{actor.email} updated the target date to None",
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -234,7 +234,7 @@ def track_target_date(
|
||||
field="target_date",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the target date to {requested_data.get('target_date')}",
|
||||
comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -260,7 +260,7 @@ def track_start_date(
|
||||
field="start_date",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the start date to None",
|
||||
comment=f"{actor.email} updated the start date to None",
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -274,7 +274,7 @@ def track_start_date(
|
||||
field="start_date",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the start date to {requested_data.get('start_date')}",
|
||||
comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -303,7 +303,7 @@ def track_labels(
|
||||
field="labels",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"added label {label.name}",
|
||||
comment=f"{actor.email} added label {label.name}",
|
||||
new_identifier=label.id,
|
||||
old_identifier=None,
|
||||
)
|
||||
@@ -324,7 +324,7 @@ def track_labels(
|
||||
field="labels",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"removed label {label.name}",
|
||||
comment=f"{actor.email} removed label {label.name}",
|
||||
old_identifier=label.id,
|
||||
new_identifier=None,
|
||||
)
|
||||
@@ -353,12 +353,12 @@ def track_assignees(
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value="",
|
||||
new_value=assignee.display_name,
|
||||
new_value=assignee.email,
|
||||
field="assignees",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"added assignee {assignee.display_name}",
|
||||
new_identifier=assignee.id,
|
||||
comment=f"{actor.email} added assignee {assignee.email}",
|
||||
new_identifier=actor.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -374,13 +374,13 @@ def track_assignees(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=assignee.display_name,
|
||||
old_value=assignee.email,
|
||||
new_value="",
|
||||
field="assignees",
|
||||
field="assignee",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"removed assignee {assignee.display_name}",
|
||||
old_identifier=assignee.id,
|
||||
comment=f"{actor.email} removed assignee {assignee.email}",
|
||||
old_identifier=actor.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -415,11 +415,11 @@ def track_blocks(
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value="",
|
||||
new_value=f"{issue.project.identifier}-{issue.sequence_id}",
|
||||
new_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
field="blocks",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"added blocking issue {project.identifier}-{issue.sequence_id}",
|
||||
comment=f"{actor.email} added blocking 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"{issue.project.identifier}-{issue.sequence_id}",
|
||||
old_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
new_value="",
|
||||
field="blocks",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"removed blocking issue {project.identifier}-{issue.sequence_id}",
|
||||
comment=f"{actor.email} removed blocking 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"{issue.project.identifier}-{issue.sequence_id}",
|
||||
new_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
field="blocking",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"added blocked by issue {project.identifier}-{issue.sequence_id}",
|
||||
comment=f"{actor.email} added blocked by 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"{issue.project.identifier}-{issue.sequence_id}",
|
||||
old_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
new_value="",
|
||||
field="blocking",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"removed blocked by issue {project.identifier}-{issue.sequence_id}",
|
||||
comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}",
|
||||
old_identifier=issue.id,
|
||||
)
|
||||
)
|
||||
@@ -517,7 +517,7 @@ def create_issue_activity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"created the issue",
|
||||
comment=f"{actor.email} created the issue",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
)
|
||||
@@ -539,7 +539,7 @@ def track_estimate_points(
|
||||
field="estimate_point",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the estimate point to None",
|
||||
comment=f"{actor.email} updated the estimate point to None",
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -553,7 +553,7 @@ def track_estimate_points(
|
||||
field="estimate_point",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the estimate point to {requested_data.get('estimate_point')}",
|
||||
comment=f"{actor.email} updated the estimate point to {requested_data.get('estimate_point')}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -567,7 +567,7 @@ def track_archive_at(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"has restored the issue",
|
||||
comment=f"{actor.email} has restored the issue",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
field="archived_at",
|
||||
@@ -661,7 +661,7 @@ def delete_issue_activity(
|
||||
IssueActivity(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"deleted the issue",
|
||||
comment=f"{actor.email} deleted the issue",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="issue",
|
||||
@@ -682,7 +682,7 @@ def create_comment_activity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"created a comment",
|
||||
comment=f"{actor.email} created a comment",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
field="comment",
|
||||
@@ -707,7 +707,7 @@ def update_comment_activity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated a comment",
|
||||
comment=f"{actor.email} updated a comment",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
field="comment",
|
||||
@@ -728,7 +728,7 @@ def delete_comment_activity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"deleted the comment",
|
||||
comment=f"{actor.email} deleted the comment",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="comment",
|
||||
@@ -766,7 +766,7 @@ def create_cycle_issue_activity(
|
||||
field="cycles",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
|
||||
comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}",
|
||||
old_identifier=old_cycle.id,
|
||||
new_identifier=new_cycle.id,
|
||||
)
|
||||
@@ -787,7 +787,7 @@ def create_cycle_issue_activity(
|
||||
field="cycles",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"added cycle {cycle.name}",
|
||||
comment=f"{actor.email} added cycle {cycle.name}",
|
||||
new_identifier=cycle.id,
|
||||
)
|
||||
)
|
||||
@@ -816,7 +816,7 @@ def delete_cycle_issue_activity(
|
||||
field="cycles",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"removed this issue from {cycle.name if cycle is not None else None}",
|
||||
comment=f"{actor.email} removed this issue from {cycle.name if cycle is not None else None}",
|
||||
old_identifier=cycle.id if cycle is not None else None,
|
||||
)
|
||||
)
|
||||
@@ -852,7 +852,7 @@ def create_module_issue_activity(
|
||||
field="modules",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated module from {old_module.name} to {new_module.name}",
|
||||
comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}",
|
||||
old_identifier=old_module.id,
|
||||
new_identifier=new_module.id,
|
||||
)
|
||||
@@ -872,7 +872,7 @@ def create_module_issue_activity(
|
||||
field="modules",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"added module {module.name}",
|
||||
comment=f"{actor.email} added module {module.name}",
|
||||
new_identifier=module.id,
|
||||
)
|
||||
)
|
||||
@@ -901,7 +901,7 @@ def delete_module_issue_activity(
|
||||
field="modules",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"removed this issue from {module.name if module is not None else None}",
|
||||
comment=f"{actor.email} removed this issue from {module.name if module is not None else None}",
|
||||
old_identifier=module.id if module is not None else None,
|
||||
)
|
||||
)
|
||||
@@ -920,7 +920,7 @@ def create_link_activity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"created a link",
|
||||
comment=f"{actor.email} created a link",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
field="link",
|
||||
@@ -944,7 +944,7 @@ def update_link_activity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated a link",
|
||||
comment=f"{actor.email} updated a link",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
field="link",
|
||||
@@ -959,22 +959,15 @@ 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,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"deleted the link",
|
||||
comment=f"{actor.email} deleted the link",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="link",
|
||||
old_value=current_instance.get("url", ""),
|
||||
new_value=""
|
||||
)
|
||||
)
|
||||
|
||||
@@ -992,11 +985,11 @@ def create_attachment_activity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"created an attachment",
|
||||
comment=f"{actor.email} created an attachment",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
field="attachment",
|
||||
new_value=current_instance.get("asset", ""),
|
||||
new_value=current_instance.get("access", ""),
|
||||
new_identifier=current_instance.get("id", None),
|
||||
)
|
||||
)
|
||||
@@ -1010,7 +1003,7 @@ def delete_attachment_activity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"deleted the attachment",
|
||||
comment=f"{actor.email} deleted the attachment",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
field="attachment",
|
||||
@@ -1035,29 +1028,23 @@ def issue_activity(
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
if type not in [
|
||||
"cycle.activity.created",
|
||||
"cycle.activity.deleted",
|
||||
"module.activity.created",
|
||||
"module.activity.deleted",
|
||||
]:
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
|
||||
if issue is not None:
|
||||
try:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
except Exception as e:
|
||||
pass
|
||||
try:
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if subscriber:
|
||||
# add the user to issue subscriber
|
||||
try:
|
||||
_ = IssueSubscriber.objects.get_or_create(
|
||||
issue_id=issue_id, subscriber=actor
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if subscriber:
|
||||
# add the user to issue subscriber
|
||||
try:
|
||||
_ = IssueSubscriber.objects.get_or_create(
|
||||
issue_id=issue_id, subscriber=actor
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
ACTIVITY_MAPPER = {
|
||||
"issue.activity.created": create_issue_activity,
|
||||
@@ -1109,80 +1096,66 @@ def issue_activity(
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
||||
if type not in [
|
||||
"cycle.activity.created",
|
||||
"cycle.activity.deleted",
|
||||
"module.activity.created",
|
||||
"module.activity.deleted",
|
||||
]:
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(project=project, issue_id=issue_id)
|
||||
.exclude(subscriber_id=actor_id)
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(project=project, issue_id=issue_id)
|
||||
.exclude(subscriber_id=actor_id)
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
|
||||
issue_assignees = list(
|
||||
IssueAssignee.objects.filter(project=project, issue_id=issue_id)
|
||||
.exclude(assignee_id=actor_id)
|
||||
.values_list("assignee", flat=True)
|
||||
)
|
||||
issue_assignees = list(
|
||||
IssueAssignee.objects.filter(project=project, issue_id=issue_id)
|
||||
.exclude(assignee_id=actor_id)
|
||||
.values_list("assignee", flat=True)
|
||||
)
|
||||
|
||||
issue_subscribers = issue_subscribers + issue_assignees
|
||||
issue_subscribers = issue_subscribers + issue_assignees
|
||||
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
# Add bot filtering
|
||||
if issue.created_by_id is not None and not issue.created_by.is_bot:
|
||||
issue_subscribers = issue_subscribers + [issue.created_by_id]
|
||||
|
||||
# Add bot filtering
|
||||
if (
|
||||
issue is not None
|
||||
and issue.created_by_id is not None
|
||||
and not issue.created_by.is_bot
|
||||
and str(issue.created_by_id) != str(actor_id)
|
||||
):
|
||||
issue_subscribers = issue_subscribers + [issue.created_by_id]
|
||||
|
||||
for subscriber in issue_subscribers:
|
||||
for issue_activity in issue_activities_created:
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
title=issue_activity.comment,
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.id),
|
||||
"verb": str(issue_activity.verb),
|
||||
"field": str(issue_activity.field),
|
||||
"actor": str(issue_activity.actor_id),
|
||||
"new_value": str(issue_activity.new_value),
|
||||
"old_value": str(issue_activity.old_value),
|
||||
"issue_comment": str(
|
||||
issue_activity.issue_comment.comment_stripped
|
||||
if issue_activity.issue_comment is not None
|
||||
else ""
|
||||
),
|
||||
},
|
||||
issue = Issue.objects.get(project=project, pk=issue_id)
|
||||
for subscriber in issue_subscribers:
|
||||
for issue_activity in issue_activities_created:
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
title=issue_activity.comment,
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
)
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.id),
|
||||
"verb": str(issue_activity.verb),
|
||||
"field": str(issue_activity.field),
|
||||
"actor": str(issue_activity.actor_id),
|
||||
"new_value": str(issue_activity.new_value),
|
||||
"old_value": str(issue_activity.old_value),
|
||||
"issue_comment": str(
|
||||
issue_activity.issue_comment.comment_stripped if issue_activity.issue_comment is not None else ""
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk create notifications
|
||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
||||
# Bulk create notifications
|
||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
|
||||
@@ -49,11 +49,6 @@ def archive_old_issues():
|
||||
Q(issue_module__module__target_date__lt=timezone.now().date())
|
||||
& Q(issue_module__isnull=False)
|
||||
),
|
||||
).filter(
|
||||
Q(issue_inbox__status=1)
|
||||
| Q(issue_inbox__status=-1)
|
||||
| Q(issue_inbox__status=2)
|
||||
| Q(issue_inbox__isnull=True)
|
||||
)
|
||||
|
||||
# Check if Issues
|
||||
@@ -70,7 +65,7 @@ def archive_old_issues():
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"archived_at": str(issue.archived_at)}),
|
||||
requested_data=json.dumps({"archived_at": issue.archived_at}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
@@ -116,11 +111,6 @@ def close_old_issues():
|
||||
Q(issue_module__module__target_date__lt=timezone.now().date())
|
||||
& Q(issue_module__isnull=False)
|
||||
),
|
||||
).filter(
|
||||
Q(issue_inbox__status=1)
|
||||
| Q(issue_inbox__status=-1)
|
||||
| Q(issue_inbox__status=2)
|
||||
| Q(issue_inbox__isnull=True)
|
||||
)
|
||||
|
||||
# Check if Issues
|
||||
@@ -140,7 +130,7 @@ def close_old_issues():
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"closed_to": str(issue.state_id)}),
|
||||
requested_data=json.dumps({"closed_to": issue.state_id}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
# Python imports
|
||||
import csv
|
||||
import io
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue
|
||||
|
||||
@shared_task
|
||||
def issue_export_task(email, data, slug, exporter_name):
|
||||
try:
|
||||
|
||||
project_ids = data.get("project_id", [])
|
||||
issues_filter = {"workspace__slug": slug}
|
||||
|
||||
if project_ids:
|
||||
issues_filter["project_id__in"] = project_ids
|
||||
|
||||
issues = (
|
||||
Issue.objects.filter(**issues_filter)
|
||||
.select_related("project", "workspace", "state", "parent", "created_by")
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
|
||||
)
|
||||
.values_list(
|
||||
"project__identifier",
|
||||
"sequence_id",
|
||||
"name",
|
||||
"description_stripped",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"state__name",
|
||||
"project__name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"completed_at",
|
||||
"archived_at",
|
||||
"issue_cycle__cycle__name",
|
||||
"issue_cycle__cycle__start_date",
|
||||
"issue_cycle__cycle__end_date",
|
||||
"issue_module__module__name",
|
||||
"issue_module__module__start_date",
|
||||
"issue_module__module__target_date",
|
||||
"created_by__first_name",
|
||||
"created_by__last_name",
|
||||
"assignees__first_name",
|
||||
"assignees__last_name",
|
||||
"labels__name",
|
||||
)
|
||||
)
|
||||
|
||||
# CSV header
|
||||
header = [
|
||||
"Issue ID",
|
||||
"Project",
|
||||
"Name",
|
||||
"Description",
|
||||
"State",
|
||||
"Priority",
|
||||
"Created By",
|
||||
"Assignee",
|
||||
"Labels",
|
||||
"Cycle Name",
|
||||
"Cycle Start Date",
|
||||
"Cycle End Date",
|
||||
"Module Name",
|
||||
"Module Start Date",
|
||||
"Module Target Date",
|
||||
"Created At"
|
||||
"Updated At"
|
||||
"Completed At"
|
||||
"Archived At"
|
||||
]
|
||||
|
||||
# Prepare the CSV data
|
||||
rows = [header]
|
||||
|
||||
# Write data for each issue
|
||||
for issue in issues:
|
||||
(
|
||||
project_identifier,
|
||||
sequence_id,
|
||||
name,
|
||||
description,
|
||||
priority,
|
||||
start_date,
|
||||
target_date,
|
||||
state_name,
|
||||
project_name,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
archived_at,
|
||||
cycle_name,
|
||||
cycle_start_date,
|
||||
cycle_end_date,
|
||||
module_name,
|
||||
module_start_date,
|
||||
module_target_date,
|
||||
created_by_first_name,
|
||||
created_by_last_name,
|
||||
assignees_first_names,
|
||||
assignees_last_names,
|
||||
labels_names,
|
||||
) = issue
|
||||
|
||||
created_by_fullname = (
|
||||
f"{created_by_first_name} {created_by_last_name}"
|
||||
if created_by_first_name and created_by_last_name
|
||||
else ""
|
||||
)
|
||||
|
||||
assignees_names = ""
|
||||
if assignees_first_names and assignees_last_names:
|
||||
assignees_names = ", ".join(
|
||||
[
|
||||
f"{assignees_first_name} {assignees_last_name}"
|
||||
for assignees_first_name, assignees_last_name in zip(
|
||||
assignees_first_names, assignees_last_names
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
labels_names = ", ".join(labels_names) if labels_names else ""
|
||||
|
||||
row = [
|
||||
f"{project_identifier}-{sequence_id}",
|
||||
project_name,
|
||||
name,
|
||||
description,
|
||||
state_name,
|
||||
priority,
|
||||
created_by_fullname,
|
||||
assignees_names,
|
||||
labels_names,
|
||||
cycle_name,
|
||||
cycle_start_date,
|
||||
cycle_end_date,
|
||||
module_name,
|
||||
module_start_date,
|
||||
module_target_date,
|
||||
start_date,
|
||||
target_date,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
archived_at,
|
||||
]
|
||||
rows.append(row)
|
||||
|
||||
# Create CSV file in-memory
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
|
||||
# Write CSV data to the buffer
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
|
||||
subject = "Your Issue Export is ready"
|
||||
|
||||
context = {
|
||||
"username": exporter_name,
|
||||
}
|
||||
|
||||
html_content = render_to_string("emails/exports/issues.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
csv_buffer.seek(0)
|
||||
msg = EmailMultiAlternatives(
|
||||
subject, text_content, settings.EMAIL_FROM, [email]
|
||||
)
|
||||
msg.attach(f"{slug}-issues-{timezone.now().date()}.csv", csv_buffer.read(), "text/csv")
|
||||
msg.send(fail_silently=False)
|
||||
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
@@ -1,266 +0,0 @@
|
||||
# Generated by Django 4.2.3 on 2023-07-19 06:52
|
||||
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import plane.db.models.user
|
||||
import uuid
|
||||
|
||||
|
||||
|
||||
def onboarding_default_steps(apps, schema_editor):
|
||||
default_onboarding_schema = {
|
||||
"workspace_join": True,
|
||||
"profile_complete": True,
|
||||
"workspace_create": True,
|
||||
"workspace_invite": True,
|
||||
}
|
||||
|
||||
Model = apps.get_model("db", "User")
|
||||
updated_user = []
|
||||
for obj in Model.objects.filter(is_onboarded=True):
|
||||
obj.onboarding_step = default_onboarding_schema
|
||||
obj.is_tour_completed = True
|
||||
updated_user.append(obj)
|
||||
|
||||
Model.objects.bulk_update(updated_user, ["onboarding_step", "is_tour_completed"], batch_size=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0036_alter_workspace_organization_size"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="issue",
|
||||
name="archived_at",
|
||||
field=models.DateField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="archive_in",
|
||||
field=models.IntegerField(
|
||||
default=0,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(0),
|
||||
django.core.validators.MaxValueValidator(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="close_in",
|
||||
field=models.IntegerField(
|
||||
default=0,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(0),
|
||||
django.core.validators.MaxValueValidator(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="default_state",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="default_state",
|
||||
to="db.state",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="is_tour_completed",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="onboarding_step",
|
||||
field=models.JSONField(default=plane.db.models.user.get_default_onboarding),
|
||||
),
|
||||
migrations.RunPython(onboarding_default_steps),
|
||||
migrations.CreateModel(
|
||||
name="Notification",
|
||||
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,
|
||||
),
|
||||
),
|
||||
("data", models.JSONField(null=True)),
|
||||
("entity_identifier", models.UUIDField(null=True)),
|
||||
("entity_name", models.CharField(max_length=255)),
|
||||
("title", models.TextField()),
|
||||
("message", models.JSONField(null=True)),
|
||||
("message_html", models.TextField(blank=True, default="<p></p>")),
|
||||
("message_stripped", models.TextField(blank=True, null=True)),
|
||||
("sender", models.CharField(max_length=255)),
|
||||
("read_at", models.DateTimeField(null=True)),
|
||||
("snoozed_till", models.DateTimeField(null=True)),
|
||||
("archived_at", models.DateTimeField(null=True)),
|
||||
(
|
||||
"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(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"receiver",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="received_notifications",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"triggered_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="triggered_notifications",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"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="notifications",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Notification",
|
||||
"verbose_name_plural": "Notifications",
|
||||
"db_table": "notifications",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="IssueSubscriber",
|
||||
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,
|
||||
),
|
||||
),
|
||||
(
|
||||
"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_subscribers",
|
||||
to="db.issue",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"subscriber",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="issue_subscribers",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"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 Subscriber",
|
||||
"verbose_name_plural": "Issue Subscribers",
|
||||
"db_table": "issue_subscribers",
|
||||
"ordering": ("-created_at",),
|
||||
"unique_together": {("issue", "subscriber")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 4.2.3 on 2023-07-20 09:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def restructure_theming(apps, schema_editor):
|
||||
Model = apps.get_model("db", "User")
|
||||
updated_user = []
|
||||
for obj in Model.objects.exclude(theme={}).all():
|
||||
current_theme = obj.theme
|
||||
updated_theme = {
|
||||
"primary": current_theme.get("accent", ""),
|
||||
"background": current_theme.get("bgBase", ""),
|
||||
"sidebarBackground": current_theme.get("sidebar", ""),
|
||||
"text": current_theme.get("textBase", ""),
|
||||
"sidebarText": current_theme.get("textBase", ""),
|
||||
"palette": f"""{current_theme.get("bgBase","")},{current_theme.get("textBase", "")},{current_theme.get("accent", "")},{current_theme.get("sidebar","")},{current_theme.get("textBase", "")}""",
|
||||
"darkPalette": current_theme.get("darkPalette", "")
|
||||
}
|
||||
obj.theme = updated_theme
|
||||
updated_user.append(obj)
|
||||
|
||||
Model.objects.bulk_update(
|
||||
updated_user, ["theme"], batch_size=100
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0037_issue_archived_at_project_archive_in_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(restructure_theming)
|
||||
]
|
||||
@@ -1,97 +0,0 @@
|
||||
# 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),
|
||||
]
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
# Generated by Django 4.2.3 on 2023-08-04 09:12
|
||||
import string
|
||||
import random
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def generate_display_name(apps, schema_editor):
|
||||
UserModel = apps.get_model("db", "User")
|
||||
updated_users = []
|
||||
for obj in UserModel.objects.all():
|
||||
obj.display_name = (
|
||||
obj.email.split("@")[0]
|
||||
if len(obj.email.split("@"))
|
||||
else "".join(random.choice(string.ascii_letters) for _ in range(6))
|
||||
)
|
||||
updated_users.append(obj)
|
||||
UserModel.objects.bulk_update(updated_users, ["display_name"], batch_size=100)
|
||||
|
||||
|
||||
def rectify_field_issue_activity(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_assignee_issue_activity(apps, schema_editor):
|
||||
Model = apps.get_model("db", "IssueActivity")
|
||||
updated_activity = []
|
||||
|
||||
# Get all the users
|
||||
User = apps.get_model("db", "User")
|
||||
users = User.objects.values("id", "email", "display_name")
|
||||
|
||||
for obj in Model.objects.filter(field="assignees"):
|
||||
if bool(obj.new_value) and not bool(obj.old_value):
|
||||
# Get user from list
|
||||
assigned_user = [
|
||||
user for user in users if user.get("email") == obj.new_value
|
||||
]
|
||||
if assigned_user:
|
||||
obj.new_value = assigned_user[0].get("display_name")
|
||||
obj.new_identifier = assigned_user[0].get("id")
|
||||
# Update the comment
|
||||
words = obj.comment.split()
|
||||
words[-1] = assigned_user[0].get("display_name")
|
||||
obj.comment = " ".join(words)
|
||||
|
||||
if bool(obj.old_value) and not bool(obj.new_value):
|
||||
# Get user from list
|
||||
assigned_user = [
|
||||
user for user in users if user.get("email") == obj.old_value
|
||||
]
|
||||
if assigned_user:
|
||||
obj.old_value = assigned_user[0].get("display_name")
|
||||
obj.old_identifier = assigned_user[0].get("id")
|
||||
# Update the comment
|
||||
words = obj.comment.split()
|
||||
words[-1] = assigned_user[0].get("display_name")
|
||||
obj.comment = " ".join(words)
|
||||
|
||||
updated_activity.append(obj)
|
||||
|
||||
Model.objects.bulk_update(
|
||||
updated_activity,
|
||||
["old_value", "new_value", "old_identifier", "new_identifier", "comment"],
|
||||
batch_size=200,
|
||||
)
|
||||
|
||||
|
||||
def update_name_activity(apps, schema_editor):
|
||||
Model = apps.get_model("db", "IssueActivity")
|
||||
update_activity = []
|
||||
for obj in Model.objects.filter(field="name"):
|
||||
obj.comment = obj.comment.replace("start date", "name")
|
||||
update_activity.append(obj)
|
||||
|
||||
Model.objects.bulk_update(update_activity, ["comment"], batch_size=1000)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0040_projectmember_preferences_user_cover_image_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="display_name",
|
||||
field=models.CharField(default="", max_length=255),
|
||||
),
|
||||
migrations.RunPython(generate_display_name),
|
||||
migrations.RunPython(rectify_field_issue_activity),
|
||||
migrations.RunPython(update_assignee_issue_activity),
|
||||
migrations.RunPython(update_name_activity),
|
||||
]
|
||||
@@ -34,8 +34,6 @@ from .issue import (
|
||||
IssueSequence,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
)
|
||||
|
||||
from .asset import FileAsset
|
||||
|
||||
@@ -209,7 +209,7 @@ class IssueAssignee(ProjectBaseModel):
|
||||
|
||||
|
||||
class IssueLink(ProjectBaseModel):
|
||||
title = models.CharField(max_length=255, null=True, blank=True)
|
||||
title = models.CharField(max_length=255, null=True)
|
||||
url = models.URLField()
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="issue_link"
|
||||
@@ -424,49 +424,6 @@ 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):
|
||||
|
||||
@@ -31,13 +31,6 @@ def get_default_props():
|
||||
"showEmptyGroups": True,
|
||||
}
|
||||
|
||||
def get_default_preferences():
|
||||
return {
|
||||
"pages": {
|
||||
"block_display": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
NETWORK_CHOICES = ((0, "Secret"), (2, "Public"))
|
||||
@@ -54,7 +47,7 @@ class Project(BaseModel):
|
||||
"db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project"
|
||||
)
|
||||
identifier = models.CharField(
|
||||
max_length=12,
|
||||
max_length=5,
|
||||
verbose_name="Project Identifier",
|
||||
)
|
||||
default_assignee = models.ForeignKey(
|
||||
@@ -154,21 +147,6 @@ 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"]
|
||||
@@ -190,7 +168,7 @@ class ProjectIdentifier(AuditModel):
|
||||
project = models.OneToOneField(
|
||||
Project, on_delete=models.CASCADE, related_name="project_identifier"
|
||||
)
|
||||
name = models.CharField(max_length=12)
|
||||
name = models.CharField(max_length=10)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "workspace"]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Python imports
|
||||
from enum import unique
|
||||
import uuid
|
||||
import string
|
||||
import random
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
@@ -19,7 +18,6 @@ from sentry_sdk import capture_exception
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
|
||||
def get_default_onboarding():
|
||||
return {
|
||||
"profile_complete": False,
|
||||
@@ -28,7 +26,6 @@ def get_default_onboarding():
|
||||
"workspace_join": False,
|
||||
}
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
id = models.UUIDField(
|
||||
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
|
||||
@@ -41,7 +38,6 @@ 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")
|
||||
@@ -84,7 +80,6 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
role = models.CharField(max_length=300, null=True, blank=True)
|
||||
is_bot = models.BooleanField(default=False)
|
||||
theme = models.JSONField(default=dict)
|
||||
display_name = models.CharField(max_length=255, default="")
|
||||
is_tour_completed = models.BooleanField(default=False)
|
||||
onboarding_step = models.JSONField(default=get_default_onboarding)
|
||||
|
||||
@@ -111,13 +106,6 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
self.token = uuid.uuid4().hex + uuid.uuid4().hex
|
||||
self.token_updated_at = timezone.now()
|
||||
|
||||
if not self.display_name:
|
||||
self.display_name = (
|
||||
self.email.split("@")[0]
|
||||
if len(self.email.split("@"))
|
||||
else "".join(random.choice(string.ascii_letters) for _ in range(6))
|
||||
)
|
||||
|
||||
if self.is_superuser:
|
||||
self.is_staff = True
|
||||
|
||||
|
||||
@@ -14,30 +14,6 @@ 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)
|
||||
@@ -71,8 +47,7 @@ class WorkspaceMember(BaseModel):
|
||||
)
|
||||
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10)
|
||||
company_role = models.TextField(null=True, blank=True)
|
||||
view_props = models.JSONField(default=get_default_props)
|
||||
default_props = models.JSONField(default=get_default_props)
|
||||
view_props = models.JSONField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "member"]
|
||||
|
||||
@@ -17,7 +17,7 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
"NAME": os.environ.get("PGUSER", "plane"),
|
||||
"USER": "",
|
||||
"PASSWORD": "",
|
||||
|
||||
@@ -17,7 +17,7 @@ DEBUG = int(os.environ.get("DEBUG", 0)) == 1
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
"NAME": "plane",
|
||||
"USER": os.environ.get("PGUSER", ""),
|
||||
"PASSWORD": os.environ.get("PGPASSWORD", ""),
|
||||
@@ -71,8 +71,6 @@ CORS_ALLOW_HEADERS = [
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
INSTALLED_APPS += ("scout_apm.django",)
|
||||
|
||||
STORAGES = {
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
@@ -272,8 +270,3 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
|
||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
||||
|
||||
# Scout Settings
|
||||
SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
|
||||
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
|
||||
SCOUT_NAME = "Plane"
|
||||
|
||||
@@ -16,7 +16,7 @@ from .common import * # noqa
|
||||
DEBUG = int(os.environ.get("DEBUG", 1)) == 1
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
"NAME": os.environ.get("PGUSER", "plane"),
|
||||
"USER": "",
|
||||
"PASSWORD": "",
|
||||
|
||||
@@ -12,18 +12,6 @@ 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(",")
|
||||
@@ -224,7 +212,6 @@ def filter_issue_state_type(params, filter, method):
|
||||
return filter
|
||||
|
||||
|
||||
|
||||
def filter_project(params, filter, method):
|
||||
if method == "GET":
|
||||
projects = params.get("project").split(",")
|
||||
@@ -281,24 +268,11 @@ 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,
|
||||
@@ -317,7 +291,6 @@ 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():
|
||||
|
||||
@@ -30,6 +30,3 @@ openai==0.27.8
|
||||
slack-sdk==3.21.3
|
||||
celery==5.3.1
|
||||
django_celery_beat==2.5.0
|
||||
psycopg-binary==3.1.9
|
||||
psycopg-c==3.1.9
|
||||
scout-apm==2.26.1
|
||||
@@ -7,5 +7,5 @@ django-storages==1.13.2
|
||||
boto3==1.27.0
|
||||
django-anymail==10.0
|
||||
django-debug-toolbar==4.1.0
|
||||
gevent==23.7.0
|
||||
gevent==22.10.2
|
||||
psycogreen==1.0.2
|
||||
@@ -89,8 +89,8 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
|
||||
<!--[if mso]>
|
||||
<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">
|
||||
<!--[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">
|
||||
<w:anchorlock/>
|
||||
<div style="display:none;">
|
||||
<center class="default-button">
|
||||
@@ -98,11 +98,11 @@
|
||||
</center>
|
||||
</div>
|
||||
</v:roundrect>
|
||||
<![endif]--> <!--[if !mso]><!-- -->
|
||||
<![endif]--> <!--[if !mso]><!-- -->
|
||||
<a href={{magic_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;">
|
||||
<p style="margin: 0;"><span style="color: #3F76FF; font-size: 14px;">Open Plane</span></p>
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
@@ -364,4 +364,4 @@
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -1,9 +0,0 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||
<html>
|
||||
Dear {{username}},<br/>
|
||||
Your requested Issue's data has been successfully exported from Plane. The export includes all relevant information about issues you requested from your selected projects.</br>
|
||||
Please find the attachment and download the CSV file. If you have any questions or need further assistance, please don't hesitate to contact our support team at <a href = "mailto: engineering@plane.com">engineering@plane.so</a>. We're here to help!</br>
|
||||
Thank you for using Plane. We hope this export will aid you in effectively managing your projects.</br>
|
||||
Regards,
|
||||
Team Plane
|
||||
</html>
|
||||
@@ -90,8 +90,8 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
|
||||
<!--[if mso]>
|
||||
<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">
|
||||
<!--[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">
|
||||
<w:anchorlock/>
|
||||
<div style="display:none;">
|
||||
<center class="default-button">
|
||||
@@ -99,11 +99,11 @@
|
||||
</center>
|
||||
</div>
|
||||
</v:roundrect>
|
||||
<![endif]--> <!--[if !mso]><!-- -->
|
||||
<![endif]--> <!--[if !mso]><!-- -->
|
||||
<a href={{invitation_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;">
|
||||
<p style="margin: 0;"><span style="color: #3F76FF;">Accept the invite</span></p>
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
@@ -346,4 +346,4 @@
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -90,8 +90,8 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
|
||||
<!--[if mso]>
|
||||
<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">
|
||||
<!--[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">
|
||||
<w:anchorlock/>
|
||||
<div style="display:none;">
|
||||
<center class="default-button">
|
||||
@@ -99,11 +99,11 @@
|
||||
</center>
|
||||
</div>
|
||||
</v:roundrect>
|
||||
<![endif]--> <!--[if !mso]><!-- -->
|
||||
<![endif]--> <!--[if !mso]><!-- -->
|
||||
<a href={{invitation_url}} class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 20px; padding-right: 20px; padding-top: 7px; width: 258px;">
|
||||
<p style="margin: 0;"><span style="color: #3F76FF;">Accept the invite</span></p>
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide">
|
||||
@@ -346,4 +346,4 @@
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -120,7 +120,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
Please check your inbox at <span className="font-medium">{watch("email")}</span>
|
||||
</p>
|
||||
)}
|
||||
<form className="space-y-4 mt-10 sm:w-[360px] mx-auto">
|
||||
<form className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto">
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="email"
|
||||
@@ -136,7 +136,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter your email address..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
className="border-custom-border-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +152,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
}}
|
||||
error={errors.token}
|
||||
placeholder="Enter code..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
className="border-custom-border-300"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -178,7 +178,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
) : errorResendingCode ? (
|
||||
"Please try again later"
|
||||
) : (
|
||||
<span className="font-medium">Resend code</span>
|
||||
"Resend code"
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
@@ -186,7 +186,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
{codeSent ? (
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center h-[46px]"
|
||||
className="w-full text-center"
|
||||
size="md"
|
||||
onClick={handleSubmit(handleSignin)}
|
||||
disabled={!isValid && isDirty}
|
||||
@@ -196,7 +196,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
className="w-full text-center h-[46px]"
|
||||
className="w-full text-center"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
handleSubmit(onSubmit)().then(() => {
|
||||
|
||||
@@ -71,7 +71,7 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter your email address..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
className="border-custom-border-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -85,7 +85,7 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
|
||||
}}
|
||||
error={errors.password}
|
||||
placeholder="Enter your password..."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
className="border-custom-border-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-xs">
|
||||
@@ -108,7 +108,7 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
|
||||
<div>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center h-[46px]"
|
||||
className="w-full text-center"
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
|
||||
@@ -78,17 +78,17 @@ export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter registered email address.."
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
className="border-custom-border-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
|
||||
<SecondaryButton
|
||||
className="w-full text-center h-[46px]"
|
||||
className="w-full text-center"
|
||||
onClick={() => setIsResettingPassword(false)}
|
||||
>
|
||||
Go Back
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="submit" className="w-full text-center h-[46px]" loading={isSubmitting}>
|
||||
<PrimaryButton type="submit" className="w-full text-center" loading={isSubmitting}>
|
||||
{isSubmitting ? "Sending link..." : "Send reset link"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
@@ -44,9 +44,9 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn })
|
||||
<Link
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
>
|
||||
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
|
||||
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80">
|
||||
<Image
|
||||
src={theme === "dark" ? githubWhiteImage : githubBlackImage}
|
||||
src={theme === "light" ? githubBlackImage : githubWhiteImage}
|
||||
height={20}
|
||||
width={20}
|
||||
alt="GitHub Logo"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
|
||||
|
||||
// next
|
||||
import Script from "next/script";
|
||||
|
||||
export interface IGoogleLoginButton {
|
||||
@@ -8,36 +8,30 @@ export interface IGoogleLoginButton {
|
||||
styles?: CSSProperties;
|
||||
}
|
||||
|
||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
|
||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
const { handleSignIn } = props;
|
||||
|
||||
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
||||
|
||||
const loadScript = useCallback(() => {
|
||||
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
||||
|
||||
window?.google?.accounts.id.initialize({
|
||||
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
|
||||
callback: handleSignIn,
|
||||
});
|
||||
|
||||
try {
|
||||
window?.google?.accounts.id.renderButton(
|
||||
googleSignInButton.current,
|
||||
{
|
||||
type: "standard",
|
||||
theme: "outline",
|
||||
size: "large",
|
||||
logo_alignment: "center",
|
||||
width: 360,
|
||||
text: "signin_with",
|
||||
} as GsiButtonConfiguration // customization attributes
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
window?.google?.accounts.id.renderButton(
|
||||
googleSignInButton.current,
|
||||
{
|
||||
type: "standard",
|
||||
theme: "outline",
|
||||
size: "large",
|
||||
logo_alignment: "center",
|
||||
width: "360",
|
||||
text: "continue_with",
|
||||
} as GsiButtonConfiguration // customization attributes
|
||||
);
|
||||
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
|
||||
|
||||
setGsiScriptLoaded(true);
|
||||
}, [handleSignIn, gsiScriptLoaded]);
|
||||
|
||||
@@ -54,7 +48,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
|
||||
<>
|
||||
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
|
||||
<div
|
||||
className="overflow-hidden rounded w-full"
|
||||
className="overflow-hidden rounded w-full flex justify-center items-center !text-sm !font-medium !text-custom-text-100"
|
||||
id="googleSignInButton"
|
||||
ref={googleSignInButton}
|
||||
/>
|
||||
|
||||
@@ -14,17 +14,17 @@ type Props = {
|
||||
export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) => {
|
||||
let tooltipValue: string | number = "";
|
||||
|
||||
const renderAssigneeName = (assigneeId: string): string => {
|
||||
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
|
||||
|
||||
if (!assignee) return "No assignee";
|
||||
|
||||
return assignee.assignees__display_name || "No assignee";
|
||||
};
|
||||
|
||||
if (params.segment) {
|
||||
if (DATE_KEYS.includes(params.segment)) tooltipValue = renderMonthAndYear(datum.id);
|
||||
else tooltipValue = datum.id;
|
||||
else if (params.segment === "assignees__email") {
|
||||
const assignee = analytics.extras.assignee_details.find(
|
||||
(a) => a.assignees__email === datum.id
|
||||
);
|
||||
|
||||
if (assignee)
|
||||
tooltipValue = assignee.assignees__first_name + " " + assignee.assignees__last_name;
|
||||
else tooltipValue = "No assignees";
|
||||
} else tooltipValue = datum.id;
|
||||
} else {
|
||||
if (DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue;
|
||||
else tooltipValue = datum.id === "count" ? "Issue count" : "Estimate";
|
||||
@@ -49,10 +49,7 @@ export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) =>
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{params.segment === "assignees__id"
|
||||
? renderAssigneeName(tooltipValue.toString())
|
||||
: tooltipValue}
|
||||
:
|
||||
{tooltipValue}:
|
||||
</span>
|
||||
<span>{datum.value}</span>
|
||||
</div>
|
||||
|
||||
@@ -29,14 +29,6 @@ export const AnalyticsGraph: React.FC<Props> = ({
|
||||
yAxisKey,
|
||||
fullScreen,
|
||||
}) => {
|
||||
const renderAssigneeName = (assigneeId: string): string => {
|
||||
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
|
||||
|
||||
if (!assignee) return "?";
|
||||
|
||||
return assignee.assignees__display_name || "?";
|
||||
};
|
||||
|
||||
const generateYAxisTickValues = () => {
|
||||
if (!analytics) return [];
|
||||
|
||||
@@ -78,17 +70,17 @@ export const AnalyticsGraph: React.FC<Props> = ({
|
||||
height={fullScreen ? "400px" : "300px"}
|
||||
margin={{
|
||||
right: 20,
|
||||
bottom: params.x_axis === "assignees__id" ? 50 : longestXAxisLabel.length * 5 + 20,
|
||||
bottom: params.x_axis === "assignees__email" ? 50 : longestXAxisLabel.length * 5 + 20,
|
||||
}}
|
||||
axisBottom={{
|
||||
tickSize: 0,
|
||||
tickPadding: 10,
|
||||
tickRotation: barGraphData.data.length > 7 ? -45 : 0,
|
||||
renderTick:
|
||||
params.x_axis === "assignees__id"
|
||||
params.x_axis === "assignees__email"
|
||||
? (datum) => {
|
||||
const avatar = analytics.extras.assignee_details?.find(
|
||||
(a) => a?.assignees__display_name === datum?.value
|
||||
(a) => a?.assignees__email === datum?.value
|
||||
)?.assignees__avatar;
|
||||
|
||||
if (avatar && avatar !== "")
|
||||
@@ -109,11 +101,7 @@ export const AnalyticsGraph: React.FC<Props> = ({
|
||||
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||
<circle cy={18} r={8} fill="#374151" />
|
||||
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
|
||||
{params.x_axis === "assignees__id"
|
||||
? datum.value && datum.value !== "None"
|
||||
? renderAssigneeName(datum.value)[0].toUpperCase()
|
||||
: "?"
|
||||
: datum.value && datum.value !== "None"
|
||||
{datum.value && datum.value !== "None"
|
||||
? `${datum.value}`.toUpperCase()[0]
|
||||
: "?"}
|
||||
</text>
|
||||
|
||||
@@ -227,7 +227,12 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
</span>
|
||||
) : project.icon_prop ? (
|
||||
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
|
||||
{renderEmoji(project.icon_prop)}
|
||||
<span
|
||||
style={{ color: project.icon_prop.color }}
|
||||
className="material-symbols-rounded text-lg"
|
||||
>
|
||||
{project.icon_prop.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
@@ -277,7 +282,9 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-custom-text-200">Lead</h6>
|
||||
<span>{cycleDetails.owned_by?.display_name}</span>
|
||||
<span>
|
||||
{cycleDetails.owned_by?.first_name} {cycleDetails.owned_by?.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-custom-text-200">Start Date</h6>
|
||||
@@ -303,7 +310,10 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-custom-text-200">Lead</h6>
|
||||
<span>{moduleDetails.lead_detail?.display_name}</span>
|
||||
<span>
|
||||
{moduleDetails.lead_detail?.first_name}{" "}
|
||||
{moduleDetails.lead_detail?.last_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-custom-text-200">Start Date</h6>
|
||||
@@ -332,7 +342,12 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
</div>
|
||||
) : projectDetails?.icon_prop ? (
|
||||
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
|
||||
{renderEmoji(projectDetails.icon_prop)}
|
||||
<span
|
||||
style={{ color: projectDetails.icon_prop.color }}
|
||||
className="material-symbols-rounded text-lg"
|
||||
>
|
||||
{projectDetails.icon_prop.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
@@ -345,8 +360,11 @@ 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.find((n) => n.key === projectDetails?.network)?.label ??
|
||||
""}
|
||||
{
|
||||
NETWORK_CHOICES[
|
||||
`${projectDetails?.network}` as keyof typeof NETWORK_CHOICES
|
||||
]
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,12 +22,15 @@ type Props = {
|
||||
};
|
||||
|
||||
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => {
|
||||
const renderAssigneeName = (assigneeId: string): string => {
|
||||
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
|
||||
const renderAssigneeName = (email: string): string => {
|
||||
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__email === email);
|
||||
|
||||
if (!assignee) return "No assignee";
|
||||
|
||||
return assignee.assignees__display_name || "No assignee";
|
||||
if (assignee.assignees__first_name !== "")
|
||||
return assignee.assignees__first_name + " " + assignee.assignees__last_name;
|
||||
|
||||
return email;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -62,10 +65,10 @@ export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, param
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{params.segment === "assignees__id"
|
||||
? renderAssigneeName(key)
|
||||
: DATE_KEYS.includes(params.segment ?? "")
|
||||
{DATE_KEYS.includes(params.segment ?? "")
|
||||
? renderMonthAndYear(key)
|
||||
: params.segment === "assignees__email"
|
||||
? renderAssigneeName(key)
|
||||
: key}
|
||||
</div>
|
||||
</th>
|
||||
@@ -105,7 +108,7 @@ export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, param
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{params.x_axis === "assignees__id"
|
||||
{params.x_axis === "assignees__email"
|
||||
? renderAssigneeName(`${item.name}`)
|
||||
: addSpaceIfCamelCase(`${item.name}`)}
|
||||
</td>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
type Props = {
|
||||
users: {
|
||||
avatar: string | null;
|
||||
display_name: string | null;
|
||||
email: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
count: number;
|
||||
@@ -16,7 +16,7 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
|
||||
<div className="mt-3 space-y-3">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.display_name ?? "None"}
|
||||
key={user.email ?? "None"}
|
||||
className="flex items-start justify-between gap-4 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -25,16 +25,16 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
|
||||
<img
|
||||
src={user.avatar}
|
||||
className="absolute top-0 left-0 h-full w-full object-cover rounded-full"
|
||||
alt={user.display_name ?? "None"}
|
||||
alt={user.email ?? "None"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid place-items-center flex-shrink-0 rounded-full bg-gray-700 text-[11px] capitalize text-white h-4 w-4">
|
||||
{user.display_name !== "" ? user?.display_name?.[0] : "?"}
|
||||
{user.firstName !== "" ? user.firstName[0] : "?"}
|
||||
</div>
|
||||
)}
|
||||
<span className="break-words text-custom-text-200">
|
||||
{user.display_name !== "" ? `${user.display_name}` : "No assignee"}
|
||||
{user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex-shrink-0">{user.count}</span>
|
||||
|
||||
@@ -56,9 +56,9 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
|
||||
<AnalyticsLeaderboard
|
||||
users={defaultAnalytics.most_issue_created_user?.map((user) => ({
|
||||
avatar: user?.created_by__avatar,
|
||||
email: user?.created_by__email,
|
||||
firstName: user?.created_by__first_name,
|
||||
lastName: user?.created_by__last_name,
|
||||
display_name: user?.created_by__display_name,
|
||||
count: user?.count,
|
||||
}))}
|
||||
title="Most issues created"
|
||||
@@ -66,9 +66,9 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
|
||||
<AnalyticsLeaderboard
|
||||
users={defaultAnalytics.most_issue_closed_user?.map((user) => ({
|
||||
avatar: user?.assignees__avatar,
|
||||
email: user?.assignees__email,
|
||||
firstName: user?.assignees__first_name,
|
||||
lastName: user?.assignees__last_name,
|
||||
display_name: user?.assignees__display_name,
|
||||
count: user?.count,
|
||||
}))}
|
||||
title="Most issues closed"
|
||||
|
||||
@@ -16,20 +16,23 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
{defaultAnalytics.pending_issue_user.length > 0 ? (
|
||||
<BarGraph
|
||||
data={defaultAnalytics.pending_issue_user}
|
||||
indexBy="assignees__display_name"
|
||||
indexBy="assignees__email"
|
||||
keys={["count"]}
|
||||
height="250px"
|
||||
colors={() => `#f97316`}
|
||||
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)}
|
||||
tooltip={(datum) => {
|
||||
const assignee = defaultAnalytics.pending_issue_user.find(
|
||||
(a) => a.assignees__display_name === `${datum.indexValue}`
|
||||
(a) => a.assignees__email === `${datum.indexValue}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
<span className="font-medium text-custom-text-200">
|
||||
{assignee ? assignee.assignees__display_name : "No assignee"}:{" "}
|
||||
{assignee
|
||||
? assignee.assignees__first_name + " " + assignee.assignees__last_name
|
||||
: "No assignee"}
|
||||
:{" "}
|
||||
</span>
|
||||
{datum.value}
|
||||
</div>
|
||||
|
||||
@@ -9,46 +9,54 @@ type Props = {
|
||||
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||
};
|
||||
|
||||
export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
<div className="py-3 border border-custom-border-200 rounded-[10px]">
|
||||
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
|
||||
{defaultAnalytics.issue_completed_month_wise.length > 0 ? (
|
||||
<LineGraph
|
||||
data={[
|
||||
{
|
||||
id: "issues_closed",
|
||||
color: "rgb(var(--color-primary-100))",
|
||||
data: MONTHS_LIST.map((month) => ({
|
||||
x: month.label.substring(0, 3),
|
||||
y:
|
||||
defaultAnalytics.issue_completed_month_wise.find(
|
||||
(data) => data.month === month.value
|
||||
)?.count || 0,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map(
|
||||
(data) => data.count
|
||||
)}
|
||||
height="300px"
|
||||
colors={(datum) => datum.color}
|
||||
curve="monotoneX"
|
||||
margin={{ top: 20 }}
|
||||
enableSlices="x"
|
||||
sliceTooltip={(datum) => (
|
||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
{datum.slice.points[0].data.yFormatted}
|
||||
<span className="text-custom-text-200"> issues closed in </span>
|
||||
{datum.slice.points[0].data.xFormatted}
|
||||
</div>
|
||||
)}
|
||||
theme={{
|
||||
background: "rgb(var(--color-background-100))",
|
||||
}}
|
||||
enableArea
|
||||
/>
|
||||
) : (
|
||||
<div className="text-custom-text-200 text-center text-sm py-8">No matching data found.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) => {
|
||||
const currentMonth = new Date().getMonth();
|
||||
const startMonth = Math.floor(currentMonth / 3) * 3 + 1;
|
||||
const quarterMonthsList = [startMonth, startMonth + 1, startMonth + 2];
|
||||
|
||||
return (
|
||||
<div className="py-3 border border-custom-border-200 rounded-[10px]">
|
||||
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
|
||||
{defaultAnalytics.issue_completed_month_wise.length > 0 ? (
|
||||
<LineGraph
|
||||
data={[
|
||||
{
|
||||
id: "issues_closed",
|
||||
color: "rgb(var(--color-primary-100))",
|
||||
data: MONTHS_LIST.map((month) => ({
|
||||
x: month.label.substring(0, 3),
|
||||
y:
|
||||
defaultAnalytics.issue_completed_month_wise.find(
|
||||
(data) => data.month === month.value
|
||||
)?.count || 0,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map((data) => {
|
||||
if (quarterMonthsList.includes(data.month)) return data.count;
|
||||
|
||||
return 0;
|
||||
})}
|
||||
height="300px"
|
||||
colors={(datum) => datum.color}
|
||||
curve="monotoneX"
|
||||
margin={{ top: 20 }}
|
||||
enableSlices="x"
|
||||
sliceTooltip={(datum) => (
|
||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
{datum.slice.points[0].data.yFormatted}
|
||||
<span className="text-custom-text-200"> issues closed in </span>
|
||||
{datum.slice.points[0].data.xFormatted}
|
||||
</div>
|
||||
)}
|
||||
theme={{
|
||||
background: "rgb(var(--color-background-100))",
|
||||
}}
|
||||
enableArea
|
||||
/>
|
||||
) : (
|
||||
<div className="text-custom-text-200 text-center text-sm py-8">No matching data found.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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-300 bg-custom-background-90">
|
||||
<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 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>
|
||||
|
||||
@@ -37,7 +37,8 @@ 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")
|
||||
@@ -52,14 +53,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,
|
||||
@@ -76,7 +77,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-300 bg-custom-background-90">
|
||||
<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 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>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 = {
|
||||
@@ -13,7 +14,7 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center flex-grow w-full whitespace-nowrap overflow-hidden overflow-ellipsis">
|
||||
<div className="flex items-center">
|
||||
<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"
|
||||
@@ -34,36 +35,22 @@ type BreadcrumbItemProps = {
|
||||
title: string;
|
||||
link?: string;
|
||||
icon?: any;
|
||||
linkTruncate?: boolean;
|
||||
unshrinkTitle?: boolean;
|
||||
};
|
||||
|
||||
const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({
|
||||
title,
|
||||
link,
|
||||
icon,
|
||||
linkTruncate = false,
|
||||
unshrinkTitle = false,
|
||||
}) => (
|
||||
const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) => (
|
||||
<>
|
||||
{link ? (
|
||||
<Link href={link}>
|
||||
<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" : ""}`}
|
||||
>
|
||||
<a className="border-r-2 border-custom-sidebar-border-200 px-3 text-sm">
|
||||
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
|
||||
{icon ?? null}
|
||||
{title}
|
||||
</p>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className={`px-3 text-sm truncate ${unshrinkTitle ? "flex-shrink-0" : ""}`}>
|
||||
<p className={`truncate ${icon ? "flex items-center gap-2" : ""}`}>
|
||||
<div className="max-w-64 px-3 text-sm">
|
||||
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
|
||||
{icon}
|
||||
<span className="break-words">{title}</span>
|
||||
</p>
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Command } from "cmdk";
|
||||
import { THEMES_OBJ } from "constants/themes";
|
||||
import { useTheme } from "next-themes";
|
||||
import { SettingIcon } from "components/icons";
|
||||
import userService from "services/user.service";
|
||||
import useUser from "hooks/use-user";
|
||||
|
||||
type Props = {
|
||||
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -14,50 +12,24 @@ type Props = {
|
||||
|
||||
export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const { user, mutateUser } = useUser();
|
||||
|
||||
const updateUserTheme = (newTheme: string) => {
|
||||
if (!user) return;
|
||||
|
||||
setTheme(newTheme);
|
||||
|
||||
mutateUser((prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
theme: {
|
||||
...prevData.theme,
|
||||
theme: newTheme,
|
||||
},
|
||||
};
|
||||
}, false);
|
||||
|
||||
userService.updateUser({
|
||||
theme: {
|
||||
...user.theme,
|
||||
theme: newTheme,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// useEffect only runs on the client, so now we can safely show the UI
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{THEMES_OBJ.filter((t) => t.value !== "custom").map((theme) => (
|
||||
{THEMES_OBJ.map((theme) => (
|
||||
<Command.Item
|
||||
key={theme.value}
|
||||
onSelect={() => {
|
||||
updateUserTheme(theme.value);
|
||||
setTheme(theme.value);
|
||||
setIsPaletteOpen(false);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
|
||||
+19
-15
@@ -1,23 +1,19 @@
|
||||
import React, { Dispatch, SetStateAction, useCallback } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
import React, { Dispatch, SetStateAction, useCallback } from "react";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// cmdk
|
||||
import { Command } from "cmdk";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useProjectMembers from "hooks/use-project-members";
|
||||
// constants
|
||||
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
// ui
|
||||
import { Avatar } from "components/ui";
|
||||
// icons
|
||||
import { CheckIcon } from "components/icons";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
// constants
|
||||
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
// icons
|
||||
import { CheckIcon } from "components/icons";
|
||||
import projectService from "services/project.service";
|
||||
import { Avatar } from "components/ui";
|
||||
|
||||
type Props = {
|
||||
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -29,17 +25,25 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue,
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { members } = useProjectMembers(workspaceSlug as string, projectId as string);
|
||||
const { data: members } = useSWR(
|
||||
projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const options =
|
||||
members?.map(({ member }) => ({
|
||||
value: member.id,
|
||||
query: member.display_name,
|
||||
query:
|
||||
(member.first_name && member.first_name !== "" ? member.first_name : member.email) +
|
||||
" " +
|
||||
member.last_name ?? "",
|
||||
content: (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar user={member} />
|
||||
{member.display_name}
|
||||
{member.first_name && member.first_name !== "" ? member.first_name : member.email}
|
||||
</div>
|
||||
{issue.assignees.includes(member.id) && (
|
||||
<div>
|
||||
+1
-1
@@ -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>) => {
|
||||
@@ -1,776 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// cmdk
|
||||
import { Command } from "cmdk";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import workspaceService from "services/workspace.service";
|
||||
import issuesService from "services/issues.service";
|
||||
import inboxService from "services/inbox.service";
|
||||
// hooks
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
import useDebounce from "hooks/use-debounce";
|
||||
import useUser from "hooks/use-user";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import {
|
||||
ChangeInterfaceTheme,
|
||||
ChangeIssueAssignee,
|
||||
ChangeIssuePriority,
|
||||
ChangeIssueState,
|
||||
commandGroups,
|
||||
} from "components/command-palette";
|
||||
// ui
|
||||
import { Icon, Loader, ToggleSwitch, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { DiscordIcon, GithubIcon, SettingIcon } from "components/icons";
|
||||
import { InboxIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IWorkspaceSearchResults } from "types";
|
||||
// fetch-keys
|
||||
import { INBOX_LIST, ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
deleteIssue: () => void;
|
||||
isPaletteOpen: boolean;
|
||||
setIsPaletteOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPaletteOpen }) => {
|
||||
const [placeholder, setPlaceholder] = useState("Type a command or search...");
|
||||
|
||||
const [resultsCount, setResultsCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [results, setResults] = useState<IWorkspaceSearchResults>({
|
||||
results: {
|
||||
workspace: [],
|
||||
project: [],
|
||||
issue: [],
|
||||
cycle: [],
|
||||
module: [],
|
||||
issue_view: [],
|
||||
page: [],
|
||||
},
|
||||
});
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
|
||||
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
const page = pages[pages.length - 1];
|
||||
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { user } = useUser();
|
||||
const { projectDetails } = useProjectDetails();
|
||||
|
||||
const { data: issueDetails } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: inboxList } = useSWR(
|
||||
workspaceSlug && projectId ? INBOX_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => inboxService.getInboxes(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const updateIssue = useCallback(
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
...formData,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const payload = { ...formData };
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
|
||||
.then(() => {
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
mutate(ISSUE_DETAILS(issueId as string));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, issueId, projectId, user]
|
||||
);
|
||||
|
||||
const handleIssueAssignees = (assignee: string) => {
|
||||
if (!issueDetails) return;
|
||||
|
||||
setIsPaletteOpen(false);
|
||||
const updatedAssignees = issueDetails.assignees ?? [];
|
||||
|
||||
if (updatedAssignees.includes(assignee)) {
|
||||
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
|
||||
} else {
|
||||
updatedAssignees.push(assignee);
|
||||
}
|
||||
updateIssue({ assignees_list: updatedAssignees });
|
||||
};
|
||||
|
||||
const redirect = (path: string) => {
|
||||
setIsPaletteOpen(false);
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
const createNewWorkspace = () => {
|
||||
setIsPaletteOpen(false);
|
||||
router.push("/create-workspace");
|
||||
};
|
||||
|
||||
const copyIssueUrlToClipboard = useCallback(() => {
|
||||
if (!router.query.issueId) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
}, [router, setToastAlert]);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
setIsSearching(true);
|
||||
workspaceService
|
||||
.searchWorkspace(workspaceSlug as string, {
|
||||
...(projectId ? { project_id: projectId.toString() } : {}),
|
||||
search: debouncedSearchTerm,
|
||||
workspace_search: !projectId ? true : isWorkspaceLevel,
|
||||
})
|
||||
.then((results) => {
|
||||
setResults(results);
|
||||
const count = Object.keys(results.results).reduce(
|
||||
(accumulator, key) => (results.results as any)[key].length + accumulator,
|
||||
0
|
||||
);
|
||||
setResultsCount(count);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
});
|
||||
} else {
|
||||
setResults({
|
||||
results: {
|
||||
workspace: [],
|
||||
project: [],
|
||||
issue: [],
|
||||
cycle: [],
|
||||
module: [],
|
||||
issue_view: [],
|
||||
page: [],
|
||||
},
|
||||
});
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
},
|
||||
[debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes
|
||||
);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Transition.Root
|
||||
show={isPaletteOpen}
|
||||
afterLeave={() => {
|
||||
setSearchTerm("");
|
||||
}}
|
||||
as={React.Fragment}
|
||||
>
|
||||
<Dialog as="div" className="relative z-30" onClose={() => setIsPaletteOpen(false)}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-custom-border-200 divide-opacity-10 rounded-xl border border-custom-border-200 bg-custom-background-100 shadow-2xl transition-all">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// when search is empty and page is undefined
|
||||
// when user tries to close the modal with esc
|
||||
if (e.key === "Escape" && !page && !searchTerm) {
|
||||
setIsPaletteOpen(false);
|
||||
}
|
||||
// Escape goes to previous page
|
||||
// Backspace goes to previous page when search is empty
|
||||
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
|
||||
e.preventDefault();
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
setPlaceholder("Type a command or search...");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex sm:items-center gap-4 p-3 pb-0 ${
|
||||
issueDetails ? "flex-col sm:flex-row justify-between" : "justify-end"
|
||||
}`}
|
||||
>
|
||||
{issueDetails && (
|
||||
<div className="overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
|
||||
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}{" "}
|
||||
{issueDetails.name}
|
||||
</div>
|
||||
)}
|
||||
{projectId && (
|
||||
<Tooltip tooltipContent="Toggle workspace level search">
|
||||
<div className="flex-shrink-0 self-end sm:self-center flex items-center gap-1 text-xs cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Workspace Level
|
||||
</button>
|
||||
<ToggleSwitch
|
||||
value={isWorkspaceLevel}
|
||||
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-custom-text-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Command.Input
|
||||
className="w-full border-0 border-b border-custom-border-200 bg-transparent p-4 pl-11 text-custom-text-100 placeholder:text-custom-text-400 outline-none focus:ring-0 text-sm"
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onValueChange={(e) => {
|
||||
setSearchTerm(e);
|
||||
}}
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Command.List className="max-h-96 overflow-scroll p-2">
|
||||
{searchTerm !== "" && (
|
||||
<h5 className="text-xs text-custom-text-100 mx-[3px] my-4">
|
||||
Search results for{" "}
|
||||
<span className="font-medium">
|
||||
{'"'}
|
||||
{searchTerm}
|
||||
{'"'}
|
||||
</span>{" "}
|
||||
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
|
||||
</h5>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
resultsCount === 0 &&
|
||||
searchTerm !== "" &&
|
||||
debouncedSearchTerm !== "" && (
|
||||
<div className="my-4 text-center text-custom-text-200">No results found.</div>
|
||||
)}
|
||||
|
||||
{(isLoading || isSearching) && (
|
||||
<Command.Loading>
|
||||
<Loader className="space-y-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
</Command.Loading>
|
||||
)}
|
||||
|
||||
{debouncedSearchTerm !== "" &&
|
||||
Object.keys(results.results).map((key) => {
|
||||
const section = (results.results as any)[key];
|
||||
const currentSection = commandGroups[key];
|
||||
|
||||
if (section.length > 0) {
|
||||
return (
|
||||
<Command.Group key={key} heading={currentSection.title}>
|
||||
{section.map((item: any) => (
|
||||
<Command.Item
|
||||
key={item.id}
|
||||
onSelect={() => {
|
||||
router.push(currentSection.path(item));
|
||||
setIsPaletteOpen(false);
|
||||
}}
|
||||
value={`${key}-${item?.name}`}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
|
||||
<Icon iconName={currentSection.icon} />
|
||||
<p className="block flex-1 truncate">
|
||||
{currentSection.itemName(item)}
|
||||
</p>
|
||||
</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
{!page && (
|
||||
<>
|
||||
{issueId && (
|
||||
<Command.Group heading="Issue actions">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change state...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-state"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="grid_view" />
|
||||
Change state...
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change priority...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-priority"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="bar_chart" />
|
||||
Change priority...
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Assign to...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-assignee"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="group" />
|
||||
Assign to...
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
handleIssueAssignees(user.id);
|
||||
setSearchTerm("");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
{issueDetails?.assignees.includes(user.id) ? (
|
||||
<>
|
||||
<Icon iconName="person_remove" />
|
||||
Un-assign from me
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon iconName="person_add" />
|
||||
Assign to me
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="delete" />
|
||||
Delete issue
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
copyIssueUrlToClipboard();
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="link" />
|
||||
Copy issue URL
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
<Command.Group heading="Issue">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "c",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
className="focus:bg-custom-background-80"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="stack" />
|
||||
Create new issue
|
||||
</div>
|
||||
<kbd>C</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
{workspaceSlug && (
|
||||
<Command.Group heading="Project">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "p",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="create_new_folder" />
|
||||
Create new project
|
||||
</div>
|
||||
<kbd>P</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{projectId && (
|
||||
<>
|
||||
<Command.Group heading="Cycle">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "q",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="contrast" />
|
||||
Create new cycle
|
||||
</div>
|
||||
<kbd>Q</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="Module">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "m",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="dataset" />
|
||||
Create new module
|
||||
</div>
|
||||
<kbd>M</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="View">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "v",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="photo_filter" />
|
||||
Create new view
|
||||
</div>
|
||||
<kbd>V</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="Page">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "d",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="article" />
|
||||
Create new page
|
||||
</div>
|
||||
<kbd>D</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
{projectDetails && projectDetails.inbox_view && (
|
||||
<Command.Group heading="Inbox">
|
||||
<Command.Item
|
||||
onSelect={() =>
|
||||
redirect(
|
||||
`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`
|
||||
)
|
||||
}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<InboxIcon className="h-4 w-4" color="#6b7280" />
|
||||
Open inbox
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Command.Group heading="Workspace Settings">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Search workspace settings...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "settings"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="settings" />
|
||||
Search settings...
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="Account">
|
||||
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="create_new_folder" />
|
||||
Create new workspace
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change interface theme...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-interface-theme"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="settings" />
|
||||
Change interface theme...
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="Help">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "h",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="rocket_launch" />
|
||||
Open keyboard shortcuts
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
window.open("https://docs.plane.so/", "_blank");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="article" />
|
||||
Open Plane documentation
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<DiscordIcon className="h-4 w-4" color="#6b7280" />
|
||||
Join our Discord
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
window.open(
|
||||
"https://github.com/makeplane/plane/issues/new/choose",
|
||||
"_blank"
|
||||
);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<GithubIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
||||
Report a bug
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
(window as any).$crisp.push(["do", "chat:open"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Icon iconName="sms" />
|
||||
Chat with us
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{page === "settings" && workspaceSlug && (
|
||||
<>
|
||||
<Command.Item
|
||||
onSelect={() => redirect(`/${workspaceSlug}/settings`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
||||
General
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => redirect(`/${workspaceSlug}/settings/members`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
||||
Members
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => redirect(`/${workspaceSlug}/settings/billing`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
||||
Billing and Plans
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => redirect(`/${workspaceSlug}/settings/integrations`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
||||
Integrations
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => redirect(`/${workspaceSlug}/settings/import-export`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
||||
Import/Export
|
||||
</div>
|
||||
</Command.Item>
|
||||
</>
|
||||
)}
|
||||
{page === "change-issue-state" && issueDetails && (
|
||||
<ChangeIssueState
|
||||
issue={issueDetails}
|
||||
setIsPaletteOpen={setIsPaletteOpen}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{page === "change-issue-priority" && issueDetails && (
|
||||
<ChangeIssuePriority
|
||||
issue={issueDetails}
|
||||
setIsPaletteOpen={setIsPaletteOpen}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{page === "change-issue-assignee" && issueDetails && (
|
||||
<ChangeIssueAssignee
|
||||
issue={issueDetails}
|
||||
setIsPaletteOpen={setIsPaletteOpen}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{page === "change-interface-theme" && (
|
||||
<ChangeInterfaceTheme setIsPaletteOpen={setIsPaletteOpen} />
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,53 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
import useSWR 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";
|
||||
// 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 { CommandK, ShortcutsModal } from "components/command-palette";
|
||||
import {
|
||||
ShortcutsModal,
|
||||
ChangeIssueState,
|
||||
ChangeIssuePriority,
|
||||
ChangeIssueAssignee,
|
||||
ChangeInterfaceTheme,
|
||||
} from "components/command-palette";
|
||||
import { BulkDeleteIssuesModal } from "components/core";
|
||||
import { CreateUpdateCycleModal } from "components/cycles";
|
||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||
@@ -17,19 +55,24 @@ 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 { copyTextToClipboard } from "helpers/string.helper";
|
||||
import {
|
||||
capitalizeFirstLetter,
|
||||
copyTextToClipboard,
|
||||
replaceUnderscoreIfSnakeCase,
|
||||
} 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 } from "constants/fetch-keys";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { INBOX_LIST, ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
|
||||
export const CommandPalette: React.FC = () => {
|
||||
const store: any = useMobxStore();
|
||||
|
||||
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
|
||||
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
@@ -41,15 +84,36 @@ 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(
|
||||
const { data: issueDetails } = useSWR<IIssue | undefined>(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
@@ -57,6 +121,59 @@ 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;
|
||||
|
||||
@@ -78,36 +195,37 @@ export const CommandPalette: React.FC = () => {
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// if on input, textarea or editor, don't do anything
|
||||
if (
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
(e.target as Element).classList?.contains("remirror-editor")
|
||||
)
|
||||
return;
|
||||
|
||||
const singleShortcutKeys = ["p", "v", "d", "h", "q", "m"];
|
||||
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
|
||||
|
||||
if (!key) return;
|
||||
|
||||
const keyPressed = key.toLowerCase();
|
||||
|
||||
const cmdClicked = ctrlKey || metaKey;
|
||||
|
||||
if (cmdClicked) {
|
||||
if (keyPressed === "k") {
|
||||
if (
|
||||
!(e.target instanceof HTMLTextAreaElement) &&
|
||||
!(e.target instanceof HTMLInputElement) &&
|
||||
!(e.target as Element).classList?.contains("remirror-editor")
|
||||
) {
|
||||
if ((ctrlKey || metaKey) && keyPressed === "k") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if (keyPressed === "c" && altKey) {
|
||||
} else if ((ctrlKey || metaKey) && keyPressed === "c") {
|
||||
if (altKey) {
|
||||
e.preventDefault();
|
||||
copyIssueUrlToClipboard();
|
||||
}
|
||||
} else if (keyPressed === "c") {
|
||||
e.preventDefault();
|
||||
copyIssueUrlToClipboard();
|
||||
} else if (keyPressed === "b") {
|
||||
setIsIssueModalOpen(true);
|
||||
} else if ((ctrlKey || metaKey) && keyPressed === "b") {
|
||||
e.preventDefault();
|
||||
toggleCollapsed();
|
||||
}
|
||||
} else {
|
||||
if (keyPressed === "c") {
|
||||
setIsIssueModalOpen(true);
|
||||
} else if (key === "Delete") {
|
||||
e.preventDefault();
|
||||
setIsBulkDeleteIssuesModalOpen(true);
|
||||
} else if (
|
||||
singleShortcutKeys.includes(keyPressed) &&
|
||||
(ctrlKey || metaKey || altKey || shiftKey)
|
||||
) {
|
||||
e.preventDefault();
|
||||
} else if (keyPressed === "p") {
|
||||
setIsProjectModalOpen(true);
|
||||
} else if (keyPressed === "v") {
|
||||
@@ -120,28 +238,106 @@ export const CommandPalette: React.FC = () => {
|
||||
setIsCreateCycleModalOpen(true);
|
||||
} else if (keyPressed === "m") {
|
||||
setIsCreateModuleModalOpen(true);
|
||||
} else if (keyPressed === "backspace" || keyPressed === "delete") {
|
||||
e.preventDefault();
|
||||
setIsBulkDeleteIssuesModalOpen(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[copyIssueUrlToClipboard, toggleCollapsed]
|
||||
[toggleCollapsed, copyIssueUrlToClipboard]
|
||||
);
|
||||
|
||||
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} />
|
||||
@@ -184,6 +380,7 @@ export const CommandPalette: React.FC = () => {
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isIssueModalOpen}
|
||||
handleClose={() => setIsIssueModalOpen(false)}
|
||||
@@ -194,11 +391,538 @@ export const CommandPalette: React.FC = () => {
|
||||
setIsOpen={setIsBulkDeleteIssuesModalOpen}
|
||||
user={user}
|
||||
/>
|
||||
<CommandK
|
||||
deleteIssue={deleteIssue}
|
||||
isPaletteOpen={isPaletteOpen}
|
||||
setIsPaletteOpen={setIsPaletteOpen}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// types
|
||||
import {
|
||||
IWorkspaceDefaultSearchResult,
|
||||
IWorkspaceIssueSearchResult,
|
||||
IWorkspaceProjectSearchResult,
|
||||
IWorkspaceSearchResult,
|
||||
} from "types";
|
||||
|
||||
export const commandGroups: {
|
||||
[key: string]: {
|
||||
icon: string;
|
||||
itemName: (item: any) => React.ReactNode;
|
||||
path: (item: any) => string;
|
||||
title: string;
|
||||
};
|
||||
} = {
|
||||
cycle: {
|
||||
icon: "contrast",
|
||||
itemName: (cycle: IWorkspaceDefaultSearchResult) => (
|
||||
<h6>
|
||||
<span className="text-custom-text-200 text-xs">{cycle.project__identifier}</span>
|
||||
{"- "}
|
||||
{cycle.name}
|
||||
</h6>
|
||||
),
|
||||
path: (cycle: IWorkspaceDefaultSearchResult) =>
|
||||
`/${cycle?.workspace__slug}/projects/${cycle?.project_id}/cycles/${cycle?.id}`,
|
||||
title: "Cycles",
|
||||
},
|
||||
issue: {
|
||||
icon: "stack",
|
||||
itemName: (issue: IWorkspaceIssueSearchResult) => (
|
||||
<h6>
|
||||
<span className="text-custom-text-200 text-xs">{issue.project__identifier}</span>
|
||||
{"- "}
|
||||
{issue.name}
|
||||
</h6>
|
||||
),
|
||||
path: (issue: IWorkspaceIssueSearchResult) =>
|
||||
`/${issue?.workspace__slug}/projects/${issue?.project_id}/issues/${issue?.id}`,
|
||||
title: "Issues",
|
||||
},
|
||||
issue_view: {
|
||||
icon: "photo_filter",
|
||||
itemName: (view: IWorkspaceDefaultSearchResult) => (
|
||||
<h6>
|
||||
<span className="text-custom-text-200 text-xs">{view.project__identifier}</span>
|
||||
{"- "}
|
||||
{view.name}
|
||||
</h6>
|
||||
),
|
||||
path: (view: IWorkspaceDefaultSearchResult) =>
|
||||
`/${view?.workspace__slug}/projects/${view?.project_id}/views/${view?.id}`,
|
||||
title: "Views",
|
||||
},
|
||||
module: {
|
||||
icon: "dataset",
|
||||
itemName: (module: IWorkspaceDefaultSearchResult) => (
|
||||
<h6>
|
||||
<span className="text-custom-text-200 text-xs">{module.project__identifier}</span>
|
||||
{"- "}
|
||||
{module.name}
|
||||
</h6>
|
||||
),
|
||||
path: (module: IWorkspaceDefaultSearchResult) =>
|
||||
`/${module?.workspace__slug}/projects/${module?.project_id}/modules/${module?.id}`,
|
||||
title: "Modules",
|
||||
},
|
||||
page: {
|
||||
icon: "article",
|
||||
itemName: (page: IWorkspaceDefaultSearchResult) => (
|
||||
<h6>
|
||||
<span className="text-custom-text-200 text-xs">{page.project__identifier}</span>
|
||||
{"- "}
|
||||
{page.name}
|
||||
</h6>
|
||||
),
|
||||
path: (page: IWorkspaceDefaultSearchResult) =>
|
||||
`/${page?.workspace__slug}/projects/${page?.project_id}/pages/${page?.id}`,
|
||||
title: "Pages",
|
||||
},
|
||||
project: {
|
||||
icon: "work",
|
||||
itemName: (project: IWorkspaceProjectSearchResult) => project?.name,
|
||||
path: (project: IWorkspaceProjectSearchResult) =>
|
||||
`/${project?.workspace__slug}/projects/${project?.id}/issues/`,
|
||||
title: "Projects",
|
||||
},
|
||||
workspace: {
|
||||
icon: "grid_view",
|
||||
itemName: (workspace: IWorkspaceSearchResult) => workspace?.name,
|
||||
path: (workspace: IWorkspaceSearchResult) => `/${workspace?.slug}/`,
|
||||
title: "Workspaces",
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
export * from "./issue";
|
||||
export * from "./change-interface-theme";
|
||||
export * from "./command-k";
|
||||
export * from "./command-pallette";
|
||||
export * from "./helpers";
|
||||
export * from "./shortcuts-modal";
|
||||
export * from "./change-issue-state";
|
||||
export * from "./change-issue-priority";
|
||||
export * from "./change-issue-assignee";
|
||||
export * from "./change-interface-theme";
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./change-issue-state";
|
||||
export * from "./change-issue-priority";
|
||||
export * from "./change-issue-assignee";
|
||||
@@ -1,531 +0,0 @@
|
||||
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" />,
|
||||
},
|
||||
start_date: {
|
||||
message: (activity, showIssue) => {
|
||||
if (!activity.new_value)
|
||||
return (
|
||||
<>
|
||||
removed the start date
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
from <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
set the start 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" />,
|
||||
},
|
||||
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)}
|
||||
</>
|
||||
);
|
||||
+39
-30
@@ -1,66 +1,75 @@
|
||||
// hooks
|
||||
import useProjectIssuesView from "hooks/use-issues-view";
|
||||
// components
|
||||
import { SingleBoard } from "components/core/views/board-view/single-board";
|
||||
import { SingleBoard } from "components/core/board-view/single-board";
|
||||
// icons
|
||||
import { getStateGroupIcon } from "components/icons";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types";
|
||||
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
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;
|
||||
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;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
|
||||
isCompleted?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
viewProps: IIssueViewProps;
|
||||
};
|
||||
|
||||
export const AllBoards: React.FC<Props> = ({
|
||||
addIssueToGroup,
|
||||
disableUserActions,
|
||||
dragDisabled,
|
||||
handleIssueAction,
|
||||
handleTrashBox,
|
||||
openIssuesListModal,
|
||||
removeIssue,
|
||||
type,
|
||||
states,
|
||||
addIssueToState,
|
||||
makeIssueCopy,
|
||||
handleEditIssue,
|
||||
openIssuesListModal,
|
||||
handleDeleteIssue,
|
||||
handleTrashBox,
|
||||
removeIssue,
|
||||
isCompleted = false,
|
||||
user,
|
||||
userAuth,
|
||||
viewProps,
|
||||
}) => {
|
||||
const { groupByProperty: selectedGroup, groupedIssues, showEmptyGroups } = viewProps;
|
||||
const {
|
||||
groupedByIssues,
|
||||
groupByProperty: selectedGroup,
|
||||
showEmptyGroups,
|
||||
} = useProjectIssuesView();
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupedIssues ? (
|
||||
{groupedByIssues ? (
|
||||
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8">
|
||||
{Object.keys(groupedIssues).map((singleGroup, index) => {
|
||||
{Object.keys(groupedByIssues).map((singleGroup, index) => {
|
||||
const currentState =
|
||||
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
|
||||
|
||||
if (!showEmptyGroups && groupedIssues[singleGroup].length === 0) return null;
|
||||
if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null;
|
||||
|
||||
return (
|
||||
<SingleBoard
|
||||
key={index}
|
||||
addIssueToGroup={() => addIssueToGroup(singleGroup)}
|
||||
type={type}
|
||||
currentState={currentState}
|
||||
disableUserActions={disableUserActions}
|
||||
dragDisabled={dragDisabled}
|
||||
groupTitle={singleGroup}
|
||||
handleIssueAction={handleIssueAction}
|
||||
handleTrashBox={handleTrashBox}
|
||||
handleEditIssue={handleEditIssue}
|
||||
makeIssueCopy={makeIssueCopy}
|
||||
addIssueToState={() => addIssueToState(singleGroup)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
openIssuesListModal={openIssuesListModal ?? null}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={removeIssue}
|
||||
isCompleted={isCompleted}
|
||||
user={user}
|
||||
userAuth={userAuth}
|
||||
viewProps={viewProps}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -68,11 +77,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(groupedIssues).map((singleGroup, index) => {
|
||||
{Object.keys(groupedByIssues).map((singleGroup, index) => {
|
||||
const currentState =
|
||||
selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null;
|
||||
|
||||
if (groupedIssues[singleGroup].length === 0)
|
||||
if (groupedByIssues[singleGroup].length === 0)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
+24
-48
@@ -8,7 +8,7 @@ import useSWR from "swr";
|
||||
import issuesService from "services/issues.service";
|
||||
import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useProjects from "hooks/use-projects";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
// component
|
||||
import { Avatar } from "components/ui";
|
||||
// icons
|
||||
@@ -16,56 +16,47 @@ 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 { IIssueViewProps, IState } from "types";
|
||||
import { IIssueLabels, IState } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
currentState?: IState | null;
|
||||
groupTitle: string;
|
||||
addIssueToGroup: () => void;
|
||||
addIssueToState: () => void;
|
||||
isCollapsed: boolean;
|
||||
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
disableUserActions: boolean;
|
||||
viewProps: IIssueViewProps;
|
||||
isCompleted?: boolean;
|
||||
};
|
||||
|
||||
export const BoardHeader: React.FC<Props> = ({
|
||||
currentState,
|
||||
groupTitle,
|
||||
addIssueToGroup,
|
||||
addIssueToState,
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
disableUserActions,
|
||||
viewProps,
|
||||
isCompleted = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { groupedIssues, groupByProperty: selectedGroup } = viewProps;
|
||||
const { groupedByIssues, groupByProperty: selectedGroup } = useIssuesView();
|
||||
|
||||
const { data: issueLabels } = useSWR(
|
||||
workspaceSlug && projectId && selectedGroup === "labels"
|
||||
? PROJECT_ISSUE_LABELS(projectId.toString())
|
||||
: null,
|
||||
workspaceSlug && projectId && selectedGroup === "labels"
|
||||
? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString())
|
||||
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
||||
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug && projectId && selectedGroup === "created_by"
|
||||
? PROJECT_MEMBERS(projectId.toString())
|
||||
: null,
|
||||
workspaceSlug && projectId && selectedGroup === "created_by"
|
||||
? () => projectService.projectMembers(workspaceSlug.toString(), projectId.toString())
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { projects } = useProjects();
|
||||
|
||||
const getGroupTitle = () => {
|
||||
let title = addSpaceIfCamelCase(groupTitle);
|
||||
|
||||
@@ -76,12 +67,12 @@ 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 = member?.display_name ?? "";
|
||||
title =
|
||||
member?.first_name && member.first_name !== ""
|
||||
? `${member.first_name} ${member.last_name}`
|
||||
: member?.email ?? "";
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -96,22 +87,9 @@ 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";
|
||||
@@ -138,7 +116,7 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
!isCollapsed ? "flex-col rounded-md bg-custom-background-90" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center ${isCollapsed ? "gap-1" : "flex-col gap-2"}`}>
|
||||
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
|
||||
<div
|
||||
className={`flex cursor-pointer items-center gap-x-3 max-w-[316px] ${
|
||||
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
|
||||
@@ -146,11 +124,9 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
>
|
||||
<span className="flex items-center">{getGroupIcon()}</span>
|
||||
<h2
|
||||
className={`text-lg font-semibold truncate ${
|
||||
selectedGroup === "created_by" ? "" : "capitalize"
|
||||
}`}
|
||||
className="text-lg font-semibold capitalize truncate"
|
||||
style={{
|
||||
writingMode: isCollapsed ? "horizontal-tb" : "vertical-rl",
|
||||
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
|
||||
}}
|
||||
>
|
||||
{getGroupTitle()}
|
||||
@@ -160,7 +136,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`}
|
||||
>
|
||||
{groupedIssues?.[groupTitle].length ?? 0}
|
||||
{groupedByIssues?.[groupTitle].length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,11 +155,11 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
<ArrowsPointingOutIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{!disableUserActions && selectedGroup !== "created_by" && (
|
||||
{!isCompleted && 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={addIssueToGroup}
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
+39
-32
@@ -5,6 +5,9 @@ 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
|
||||
@@ -14,63 +17,64 @@ import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types";
|
||||
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
addIssueToGroup: () => void;
|
||||
type?: "issue" | "cycle" | "module";
|
||||
currentState?: IState | null;
|
||||
disableUserActions: boolean;
|
||||
dragDisabled: boolean;
|
||||
groupTitle: string;
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
makeIssueCopy: (issue: IIssue) => void;
|
||||
addIssueToState: () => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
|
||||
isCompleted?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
viewProps: IIssueViewProps;
|
||||
};
|
||||
|
||||
export const SingleBoard: React.FC<Props> = ({
|
||||
addIssueToGroup,
|
||||
type,
|
||||
currentState,
|
||||
groupTitle,
|
||||
disableUserActions,
|
||||
dragDisabled,
|
||||
handleIssueAction,
|
||||
handleTrashBox,
|
||||
handleEditIssue,
|
||||
makeIssueCopy,
|
||||
addIssueToState,
|
||||
handleDeleteIssue,
|
||||
openIssuesListModal,
|
||||
handleTrashBox,
|
||||
removeIssue,
|
||||
isCompleted = false,
|
||||
user,
|
||||
userAuth,
|
||||
viewProps,
|
||||
}) => {
|
||||
// collapse/expand
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
const { groupedIssues, groupByProperty: selectedGroup, orderBy, properties } = viewProps;
|
||||
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssuesView();
|
||||
|
||||
const router = useRouter();
|
||||
const { cycleId, moduleId } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
|
||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||
|
||||
// Check if it has at least 4 tickets since it is enough to accommodate the Calendar height
|
||||
const issuesLength = groupedIssues?.[groupTitle].length;
|
||||
const issuesLength = groupedByIssues?.[groupTitle].length;
|
||||
const hasMinimumNumberOfCards = issuesLength ? issuesLength >= 4 : false;
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||
|
||||
return (
|
||||
<div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
|
||||
<BoardHeader
|
||||
addIssueToGroup={addIssueToGroup}
|
||||
addIssueToState={addIssueToState}
|
||||
currentState={currentState}
|
||||
groupTitle={groupTitle}
|
||||
isCollapsed={isCollapsed}
|
||||
setIsCollapsed={setIsCollapsed}
|
||||
disableUserActions={disableUserActions}
|
||||
viewProps={viewProps}
|
||||
isCompleted={isCompleted}
|
||||
/>
|
||||
{isCollapsed && (
|
||||
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
|
||||
@@ -108,12 +112,14 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
hasMinimumNumberOfCards ? "overflow-hidden overflow-y-scroll" : ""
|
||||
} `}
|
||||
>
|
||||
{groupedIssues?.[groupTitle].map((issue, index) => (
|
||||
{groupedByIssues?.[groupTitle].map((issue, index) => (
|
||||
<Draggable
|
||||
key={issue.id}
|
||||
draggableId={issue.id}
|
||||
index={index}
|
||||
isDragDisabled={isNotAllowed || dragDisabled}
|
||||
isDragDisabled={
|
||||
isNotAllowed || selectedGroup === "created_by" || selectedGroup === "labels"
|
||||
}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<SingleBoardIssue
|
||||
@@ -122,20 +128,21 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
snapshot={snapshot}
|
||||
type={type}
|
||||
index={index}
|
||||
selectedGroup={selectedGroup}
|
||||
issue={issue}
|
||||
groupTitle={groupTitle}
|
||||
editIssue={() => handleIssueAction(issue, "edit")}
|
||||
makeIssueCopy={() => handleIssueAction(issue, "copy")}
|
||||
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
|
||||
properties={properties}
|
||||
editIssue={() => handleEditIssue(issue)}
|
||||
makeIssueCopy={() => makeIssueCopy(issue)}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
handleTrashBox={handleTrashBox}
|
||||
removeIssue={() => {
|
||||
if (removeIssue && issue.bridge_id)
|
||||
removeIssue(issue.bridge_id, issue.id);
|
||||
}}
|
||||
disableUserActions={disableUserActions}
|
||||
isCompleted={isCompleted}
|
||||
user={user}
|
||||
userAuth={userAuth}
|
||||
viewProps={viewProps}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
@@ -154,18 +161,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={addIssueToGroup}
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
</button>
|
||||
) : (
|
||||
!disableUserActions && (
|
||||
!isCompleted && (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-custom-primary outline-none whitespace-nowrap"
|
||||
className="flex items-center gap-2 font-medium text-custom-primary outline-none"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
@@ -174,7 +181,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
position="left"
|
||||
noBorder
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={addIssueToGroup}>
|
||||
<CustomMenu.MenuItem onClick={addIssueToState}>
|
||||
Create new
|
||||
</CustomMenu.MenuItem>
|
||||
{openIssuesListModal && (
|
||||
+63
-32
@@ -15,6 +15,7 @@ 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
|
||||
@@ -22,9 +23,8 @@ import {
|
||||
ViewAssigneeSelect,
|
||||
ViewDueDateSelect,
|
||||
ViewEstimateSelect,
|
||||
ViewIssueLabel,
|
||||
ViewLabelSelect,
|
||||
ViewPrioritySelect,
|
||||
ViewStartDateSelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues";
|
||||
// ui
|
||||
@@ -45,26 +45,42 @@ import { LayerDiagonalIcon } from "components/icons";
|
||||
import { handleIssuesMutation } from "constants/issue";
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types";
|
||||
import {
|
||||
ICurrentUserResponse,
|
||||
IIssue,
|
||||
ISubIssueResponse,
|
||||
Properties,
|
||||
TIssueGroupByOptions,
|
||||
UserAuth,
|
||||
} from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/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";
|
||||
|
||||
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;
|
||||
disableUserActions: boolean;
|
||||
isCompleted?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
viewProps: IIssueViewProps;
|
||||
};
|
||||
|
||||
export const SingleBoardIssue: React.FC<Props> = ({
|
||||
@@ -72,17 +88,18 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
provided,
|
||||
snapshot,
|
||||
issue,
|
||||
properties,
|
||||
index,
|
||||
selectedGroup,
|
||||
editIssue,
|
||||
makeIssueCopy,
|
||||
removeIssue,
|
||||
groupTitle,
|
||||
handleDeleteIssue,
|
||||
handleTrashBox,
|
||||
disableUserActions,
|
||||
isCompleted = false,
|
||||
user,
|
||||
userAuth,
|
||||
viewProps,
|
||||
}) => {
|
||||
// context menu
|
||||
const [contextMenu, setContextMenu] = useState(false);
|
||||
@@ -91,16 +108,24 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
|
||||
const actionSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { groupByProperty: selectedGroup, orderBy, properties, mutateIssues } = viewProps;
|
||||
const { orderBy, params } = useIssuesView();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const partialUpdateIssue = useCallback(
|
||||
(formData: Partial<IIssue>, issue: IIssue) => {
|
||||
if (!workspaceSlug || !issue) return;
|
||||
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 (issue.parent) {
|
||||
mutate<ISubIssueResponse>(
|
||||
@@ -124,7 +149,13 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
false
|
||||
);
|
||||
} else {
|
||||
mutateIssues(
|
||||
mutate<
|
||||
| {
|
||||
[key: string]: IIssue[];
|
||||
}
|
||||
| IIssue[]
|
||||
>(
|
||||
fetchKey,
|
||||
(prevData) =>
|
||||
handleIssuesMutation(
|
||||
formData,
|
||||
@@ -139,9 +170,9 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, issue.project, issue.id, formData, user)
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user)
|
||||
.then(() => {
|
||||
mutateIssues();
|
||||
mutate(fetchKey);
|
||||
|
||||
if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
|
||||
if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
|
||||
@@ -149,13 +180,15 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
},
|
||||
[
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
cycleId,
|
||||
moduleId,
|
||||
viewId,
|
||||
groupTitle,
|
||||
index,
|
||||
selectedGroup,
|
||||
mutateIssues,
|
||||
orderBy,
|
||||
params,
|
||||
user,
|
||||
]
|
||||
);
|
||||
@@ -195,7 +228,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
|
||||
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -222,7 +255,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
Copy issue link
|
||||
</ContextMenu.Item>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
@@ -323,15 +356,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.start_date && issue.start_date && (
|
||||
<ViewStartDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.due_date && issue.target_date && (
|
||||
{properties.due_date && (
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -339,8 +364,14 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.labels && issue.labels.length > 0 && (
|
||||
<ViewIssueLabel issue={issue} maxRender={2} />
|
||||
{properties.labels && (
|
||||
<ViewLabelSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
user={user}
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
@@ -351,7 +382,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.estimate && issue.estimate_point !== null && (
|
||||
{properties.estimate && (
|
||||
<ViewEstimateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -360,7 +391,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.sub_issue_count && issue.sub_issues_count > 0 && (
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
@@ -370,7 +401,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.link && issue.link_count > 0 && (
|
||||
{properties.link && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Link" tooltipContent={`${issue.link_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
@@ -380,7 +411,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.attachment_count && issue.attachment_count > 0 && (
|
||||
{properties.attachment_count && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Attachment" tooltipContent={`${issue.attachment_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
+9
-7
@@ -34,17 +34,19 @@ import {
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
addIssueToDate: (date: string) => void;
|
||||
disableUserActions: boolean;
|
||||
isCompleted: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const CalendarView: React.FC<Props> = ({
|
||||
handleIssueAction,
|
||||
handleEditIssue,
|
||||
handleDeleteIssue,
|
||||
addIssueToDate,
|
||||
disableUserActions,
|
||||
isCompleted = false,
|
||||
user,
|
||||
userAuth,
|
||||
}) => {
|
||||
@@ -165,7 +167,7 @@ export const CalendarView: React.FC<Props> = ({
|
||||
);
|
||||
}, [currentDate]);
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||
|
||||
return calendarIssues ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
@@ -218,10 +220,10 @@ export const CalendarView: React.FC<Props> = ({
|
||||
>
|
||||
{currentViewDaysData.map((date, index) => (
|
||||
<SingleCalendarDate
|
||||
key={`${date}-${index}`}
|
||||
index={index}
|
||||
date={date}
|
||||
handleIssueAction={handleIssueAction}
|
||||
handleEditIssue={handleEditIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
addIssueToDate={addIssueToDate}
|
||||
isMonthlyView={isMonthlyView}
|
||||
showWeekEnds={showWeekEnds}
|
||||
+6
-4
@@ -13,7 +13,8 @@ import { formatDate } from "helpers/calendar.helper";
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
|
||||
handleEditIssue: (issue: IIssue) => void;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
index: number;
|
||||
date: {
|
||||
date: string;
|
||||
@@ -27,7 +28,8 @@ type Props = {
|
||||
};
|
||||
|
||||
export const SingleCalendarDate: React.FC<Props> = ({
|
||||
handleIssueAction,
|
||||
handleEditIssue,
|
||||
handleDeleteIssue,
|
||||
date,
|
||||
index,
|
||||
addIssueToDate,
|
||||
@@ -70,8 +72,8 @@ export const SingleCalendarDate: React.FC<Props> = ({
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
issue={issue}
|
||||
handleEditIssue={() => handleIssueAction(issue, "edit")}
|
||||
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
|
||||
handleEditIssue={handleEditIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
+8
-16
@@ -21,7 +21,6 @@ import {
|
||||
ViewEstimateSelect,
|
||||
ViewLabelSelect,
|
||||
ViewPrioritySelect,
|
||||
ViewStartDateSelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues";
|
||||
// icons
|
||||
@@ -193,7 +192,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
</CustomMenu>
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail.id}/issues/${issue.id}`}>
|
||||
<a className="flex w-full cursor-pointer flex-col items-start justify-center gap-1.5">
|
||||
{properties.key && (
|
||||
<Tooltip
|
||||
@@ -231,15 +230,8 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{properties.start_date && issue.start_date && (
|
||||
<ViewStartDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.due_date && issue.target_date && (
|
||||
|
||||
{properties.due_date && (
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -247,7 +239,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.labels && issue.labels.length > 0 && (
|
||||
{properties.labels && (
|
||||
<ViewLabelSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -265,7 +257,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.estimate && issue.estimate_point !== null && (
|
||||
{properties.estimate && (
|
||||
<ViewEstimateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -274,7 +266,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.sub_issue_count && issue.sub_issues_count > 0 && (
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
@@ -284,7 +276,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.link && issue.link_count > 0 && (
|
||||
{properties.link && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
@@ -294,7 +286,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.attachment_count && issue.attachment_count > 0 && (
|
||||
{properties.attachment_count && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
@@ -0,0 +1,331 @@
|
||||
import React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
// icons
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { BlockedIcon, BlockerIcon } from "components/icons";
|
||||
import { Icon } from "components/ui";
|
||||
// helpers
|
||||
import { renderShortDateWithYearFormat, timeAgo } from "helpers/date-time.helper";
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import RemirrorRichTextEditor from "components/rich-text-editor";
|
||||
|
||||
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" />,
|
||||
},
|
||||
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 text-custom-text-200" 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" />,
|
||||
},
|
||||
estimate_point: {
|
||||
message: "set the estimate point to",
|
||||
icon: <Icon iconName="change_history" 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" />,
|
||||
},
|
||||
issue: {
|
||||
message: "deleted the issue.",
|
||||
icon: <Icon iconName="delete" 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 text-custom-text-200" aria-hidden="true" />,
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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" />
|
||||
) : (
|
||||
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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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 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>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
@@ -6,31 +8,43 @@ 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";
|
||||
// helpers
|
||||
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import projectService from "services/project.service";
|
||||
import stateService from "services/state.service";
|
||||
// types
|
||||
import { IIssueFilterOptions, IIssueLabels, IState, IUserLite, TStateGroups } from "types";
|
||||
// constants
|
||||
import { STATE_GROUP_COLORS } from "constants/state";
|
||||
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fetch-keys";
|
||||
import { IIssueFilterOptions } from "types";
|
||||
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
|
||||
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 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 ?? {});
|
||||
|
||||
export const FiltersList: React.FC<Props> = ({
|
||||
filters,
|
||||
setFilters,
|
||||
clearAllFilters,
|
||||
labels,
|
||||
members,
|
||||
states,
|
||||
}) => {
|
||||
if (!filters) return <></>;
|
||||
|
||||
const nullFilters = Object.keys(filters).filter(
|
||||
@@ -39,26 +53,24 @@ export const FiltersList: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
|
||||
{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) => {
|
||||
{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) => {
|
||||
const state = states?.find((s) => s.id === stateId);
|
||||
|
||||
return (
|
||||
@@ -82,46 +94,33 @@ export const FiltersList: React.FC<Props> = ({
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
state: filters.state?.filter((s: any) => s !== stateId),
|
||||
})
|
||||
setFilters(
|
||||
{
|
||||
state: filters.state?.filter((s: any) => s !== stateId),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
)
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
})
|
||||
: 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) => (
|
||||
})}
|
||||
<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) => (
|
||||
<p
|
||||
key={priority}
|
||||
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize ${
|
||||
@@ -141,42 +140,74 @@ export const FiltersList: React.FC<Props> = ({
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
priority: filters.priority?.filter((p: any) => p !== priority),
|
||||
})
|
||||
setFilters(
|
||||
{
|
||||
priority: filters.priority?.filter((p: any) => p !== priority),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
)
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</p>
|
||||
))
|
||||
: key === "assignees"
|
||||
? filters.assignees?.map((memberId: string) => {
|
||||
const member = members?.find((m) => m.id === memberId);
|
||||
))}
|
||||
<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;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={memberId}
|
||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
|
||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
|
||||
>
|
||||
<Avatar user={member} />
|
||||
<span>{member?.display_name}</span>
|
||||
<span>{member?.first_name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
assignees: filters.assignees?.filter((p: any) => p !== memberId),
|
||||
})
|
||||
setFilters(
|
||||
{
|
||||
assignees: filters.assignees?.filter(
|
||||
(p: any) => p !== memberId
|
||||
),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
)
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: key === "created_by"
|
||||
? filters.created_by?.map((memberId: string) => {
|
||||
const member = members?.find((m) => m.id === memberId);
|
||||
})}
|
||||
<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;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -184,25 +215,40 @@ export const FiltersList: React.FC<Props> = ({
|
||||
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
|
||||
>
|
||||
<Avatar user={member} />
|
||||
<span>{member?.display_name}</span>
|
||||
<span>{member?.first_name}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
created_by: filters.created_by?.filter(
|
||||
(p: any) => p !== memberId
|
||||
),
|
||||
})
|
||||
setFilters(
|
||||
{
|
||||
created_by: filters.created_by?.filter(
|
||||
(p: any) => p !== memberId
|
||||
),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
)
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: key === "labels"
|
||||
? filters.labels?.map((labelId: string) => {
|
||||
const label = labels?.find((l) => l.id === labelId);
|
||||
})}
|
||||
<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);
|
||||
|
||||
if (!label) return null;
|
||||
const color = label.color !== "" ? label.color : "#0f172a";
|
||||
@@ -225,9 +271,12 @@ export const FiltersList: React.FC<Props> = ({
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
labels: filters.labels?.filter((l: any) => l !== labelId),
|
||||
})
|
||||
setFilters(
|
||||
{
|
||||
labels: filters.labels?.filter((l: any) => l !== labelId),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
)
|
||||
}
|
||||
>
|
||||
<XMarkIcon
|
||||
@@ -239,10 +288,22 @@ export const FiltersList: React.FC<Props> = ({
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: key === "target_date"
|
||||
? filters.target_date?.map((date: string) => {
|
||||
if (filters.target_date && filters.target_date.length <= 0) return null;
|
||||
})}
|
||||
<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;
|
||||
|
||||
const splitDate = date.split(";");
|
||||
|
||||
@@ -258,17 +319,39 @@ export const FiltersList: React.FC<Props> = ({
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
target_date: filters.target_date?.filter((d: any) => d !== date),
|
||||
})
|
||||
setFilters(
|
||||
{
|
||||
target_date: filters.target_date?.filter(
|
||||
(d: any) => d !== date
|
||||
),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
)
|
||||
}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: (filters[key] as any)?.join(", ")}
|
||||
})}
|
||||
<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]}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -280,29 +363,24 @@ export const FiltersList: React.FC<Props> = ({
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAllFilters}
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
type: null,
|
||||
state: null,
|
||||
priority: null,
|
||||
assignees: null,
|
||||
labels: null,
|
||||
created_by: null,
|
||||
target_date: null,
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-80 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<span>Clear all filters</span>
|
||||
|
||||
@@ -11,16 +11,14 @@ import useEstimateOption from "hooks/use-estimate-option";
|
||||
// components
|
||||
import { SelectFilters } from "components/views";
|
||||
// ui
|
||||
import { CustomMenu, ToggleSwitch, Tooltip } from "components/ui";
|
||||
import { CustomMenu, Icon, ToggleSwitch, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
CalendarMonthOutlined,
|
||||
FormatListBulletedOutlined,
|
||||
GridViewOutlined,
|
||||
TableChartOutlined,
|
||||
WaterfallChartOutlined,
|
||||
} from "@mui/icons-material";
|
||||
ChevronDownIcon,
|
||||
ListBulletIcon,
|
||||
Squares2X2Icon,
|
||||
CalendarDaysIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
|
||||
@@ -29,26 +27,26 @@ import { Properties, TIssueViewOptions } from "types";
|
||||
// constants
|
||||
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
|
||||
|
||||
const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
|
||||
const issueViewOptions: { type: TIssueViewOptions; icon: any }[] = [
|
||||
{
|
||||
type: "list",
|
||||
Icon: FormatListBulletedOutlined,
|
||||
icon: <ListBulletIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
type: "kanban",
|
||||
Icon: GridViewOutlined,
|
||||
icon: <Squares2X2Icon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
type: "calendar",
|
||||
Icon: CalendarMonthOutlined,
|
||||
icon: <CalendarDaysIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
type: "spreadsheet",
|
||||
Icon: TableChartOutlined,
|
||||
icon: <Icon iconName="table_chart" />,
|
||||
},
|
||||
{
|
||||
type: "gantt_chart",
|
||||
Icon: WaterfallChartOutlined,
|
||||
icon: <Icon iconName="waterfall_chart" className="rotate-90" />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -65,8 +63,6 @@ export const IssuesFilterView: React.FC = () => {
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
showEmptyGroups,
|
||||
showSubIssues,
|
||||
setShowSubIssues,
|
||||
setShowEmptyGroups,
|
||||
filters,
|
||||
setFilters,
|
||||
@@ -102,12 +98,7 @@ export const IssuesFilterView: React.FC = () => {
|
||||
}`}
|
||||
onClick={() => setIssueView(option.type)}
|
||||
>
|
||||
<option.Icon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
}}
|
||||
className={option.type === "gantt_chart" ? "rotate-90" : ""}
|
||||
/>
|
||||
{option.icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
@@ -181,106 +172,82 @@ export const IssuesFilterView: React.FC = () => {
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Group by</h4>
|
||||
<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>
|
||||
<CustomMenu
|
||||
label={
|
||||
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
|
||||
?.name ?? "Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{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>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Order by</h4>
|
||||
<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>
|
||||
<CustomMenu
|
||||
label={
|
||||
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
|
||||
"Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{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 className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Issue type</h4>
|
||||
<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>
|
||||
<CustomMenu
|
||||
label={
|
||||
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters.type)
|
||||
?.name ?? "Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{FILTER_ISSUE_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
type: option.key,
|
||||
})
|
||||
}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</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>
|
||||
<div className="w-28">
|
||||
<ToggleSwitch
|
||||
value={showEmptyGroups}
|
||||
onChange={() => setShowEmptyGroups(!showEmptyGroups)}
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={showEmptyGroups}
|
||||
onChange={() => setShowEmptyGroups(!showEmptyGroups)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex justify-end gap-x-3">
|
||||
<button type="button" onClick={() => resetFilterToDefault()}>
|
||||
@@ -300,7 +267,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 text-custom-text-200">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{Object.keys(properties).map((key) => {
|
||||
if (key === "estimate" && !isEstimateActive) return null;
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { useRouter } from "next/router";
|
||||
|
||||
// components
|
||||
import { CycleIssuesGanttChartView } from "components/cycles";
|
||||
import { IssueGanttChartView } from "components/issues";
|
||||
import { IssueGanttChartView } from "components/issues/gantt-chart";
|
||||
import { ModuleIssuesGanttChartView } from "components/modules";
|
||||
import { ViewIssuesGanttChartView } from "components/views";
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
|
||||
// next
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-dropdown
|
||||
import { useDropzone } from "react-dropzone";
|
||||
|
||||
// headless ui
|
||||
import { Tab, Transition, Popover } from "@headlessui/react";
|
||||
|
||||
@@ -17,9 +10,9 @@ import { Tab, Transition, Popover } from "@headlessui/react";
|
||||
import fileService from "services/file.service";
|
||||
|
||||
// components
|
||||
import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
import { Input, Spinner, PrimaryButton } from "components/ui";
|
||||
// hooks
|
||||
import useWorkspaceDetails from "hooks/use-workspace-details";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
|
||||
const unsplashEnabled =
|
||||
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
|
||||
@@ -45,12 +38,6 @@ 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({
|
||||
@@ -61,50 +48,10 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
|
||||
fileService.getUnsplashImages(1, searchParams)
|
||||
);
|
||||
|
||||
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,
|
||||
useOutsideClickDetector(ref, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -115,7 +62,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-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
|
||||
className="rounded-md border border-custom-border-200 bg-custom-background-80 px-2 py-1 text-xs text-custom-text-200"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
{label}
|
||||
@@ -130,24 +77,22 @@ 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 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">
|
||||
<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]">
|
||||
<Tab.Group>
|
||||
<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.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>
|
||||
<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">
|
||||
@@ -188,78 +133,8 @@ export const ImagePickerPopover: React.FC<Props> = ({ label, value, onChange })
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
<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 className="flex h-full w-full flex-col items-center justify-center">
|
||||
<p>Coming Soon...</p>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
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 "./sidebar";
|
||||
export * from "./spreadsheet-view";
|
||||
export * from "./theme";
|
||||
export * from "./views";
|
||||
export * from "./activity";
|
||||
export * from "./reaction-selector";
|
||||
export * from "./sidebar";
|
||||
export * from "./issues-view";
|
||||
export * from "./image-picker-popover";
|
||||
export * from "./feeds";
|
||||
|
||||
+153
-117
@@ -5,31 +5,42 @@ import { useRouter } from "next/router";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react-beautiful-dnd
|
||||
import { DropResult } from "react-beautiful-dnd";
|
||||
import { DragDropContext, DropResult } from "react-beautiful-dnd";
|
||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||
// 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 { FiltersList, AllViews } from "components/core";
|
||||
import {
|
||||
AllLists,
|
||||
AllBoards,
|
||||
FilterList,
|
||||
CalendarView,
|
||||
GanttChartView,
|
||||
SpreadsheetView,
|
||||
} from "components/core";
|
||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||
import { CreateUpdateViewModal } from "components/views";
|
||||
import { TransferIssues, TransferIssuesModal } from "components/cycles";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
import { EmptyState, PrimaryButton, Spinner } from "components/ui";
|
||||
// icons
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
// images
|
||||
import emptyIssue from "public/empty-state/issue.svg";
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
import { orderArrayBy } from "helpers/array.helper";
|
||||
// types
|
||||
import { IIssue, IIssueFilterOptions, IState } from "types";
|
||||
import { IIssue, IIssueFilterOptions } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_DETAILS,
|
||||
@@ -37,18 +48,19 @@ 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;
|
||||
disableUserActions?: boolean;
|
||||
isCompleted?: boolean;
|
||||
};
|
||||
|
||||
export const IssuesView: React.FC<Props> = ({
|
||||
type = "issue",
|
||||
openIssuesListModal,
|
||||
disableUserActions = false,
|
||||
isCompleted = false,
|
||||
}) => {
|
||||
// create issue modal
|
||||
const [createIssueModal, setCreateIssueModal] = useState(false);
|
||||
@@ -70,16 +82,20 @@ 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,
|
||||
@@ -87,9 +103,7 @@ 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,
|
||||
@@ -97,16 +111,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
? () => stateService.getStates(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
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 states = getStatesList(stateGroups ?? {});
|
||||
|
||||
const handleDeleteIssue = useCallback(
|
||||
(issue: IIssue) => {
|
||||
@@ -117,7 +122,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
const handleOnDragEnd = useCallback(
|
||||
async (result: DropResult) => {
|
||||
(result: DropResult) => {
|
||||
setTrashBox(false);
|
||||
|
||||
if (!result.destination || !workspaceSlug || !projectId || !groupedByIssues) return;
|
||||
@@ -185,10 +190,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
// dragged item(or issue)
|
||||
|
||||
if (selectedGroup === "priority") draggedItem.priority = destinationGroup;
|
||||
else if (selectedGroup === "state") {
|
||||
draggedItem.state = destinationGroup;
|
||||
draggedItem.state_detail = states?.find((s) => s.id === destinationGroup) as IState;
|
||||
}
|
||||
else if (selectedGroup === "state") draggedItem.state = destinationGroup;
|
||||
}
|
||||
|
||||
const sourceGroup = source.droppableId; // source group id
|
||||
@@ -204,8 +206,8 @@ export const IssuesView: React.FC<Props> = ({
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const sourceGroupArray = [...groupedByIssues[sourceGroup]];
|
||||
const destinationGroupArray = [...groupedByIssues[destinationGroup]];
|
||||
const sourceGroupArray = prevData[sourceGroup];
|
||||
const destinationGroupArray = groupedByIssues[destinationGroup];
|
||||
|
||||
sourceGroupArray.splice(source.index, 1);
|
||||
destinationGroupArray.splice(destination.index, 0, draggedItem);
|
||||
@@ -233,9 +235,7 @@ 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" &&
|
||||
@@ -280,7 +280,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
]
|
||||
);
|
||||
|
||||
const addIssueToGroup = useCallback(
|
||||
const addIssueToState = useCallback(
|
||||
(groupTitle: string) => {
|
||||
setCreateIssueModal(true);
|
||||
|
||||
@@ -334,15 +334,6 @@ 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;
|
||||
@@ -429,6 +420,13 @@ 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
|
||||
);
|
||||
@@ -462,27 +460,14 @@ 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">
|
||||
<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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FilterList filters={filters} setFilters={setFilters} />
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
if (viewId) {
|
||||
@@ -506,62 +491,113 @@ export const IssuesView: React.FC<Props> = ({
|
||||
{<div className="mt-3 border-t border-custom-border-200" />}
|
||||
</>
|
||||
)}
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
|
||||
<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 />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user