style: plane deploy (#2039)

* chore: improve access field for comments for public boards (#1956)

Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>

* chore: update user activity endpoint to return only workspace activities (#1980)

* fix: n+1 in issue history and issue automation tasks (#1994)

* fix: issue exports in self hosted instances (#1996)

* fix: issue exports in self hosted instances

* dev: remove print logs

* dev: update url creation function

* fix: changed the presigned url for self hosted exports

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* dev: remove gunicorn config (#1999)

* feat: mark all read notifications (#1963)

* feat: mark all read notifications

* fix: changed string to boolean

* fix: changed snoozed condition

* chore: project public board issue retrieve (#2003)

* chore: project public board issue retrieve

* dev: project issues list endpoint

* fix: issue public retrieve endpoint

* fix: only external comments will show in deploy boards (#2010)

* fix: issue votes (#2006)

* fix: issue votes

* fix: added default as 1 in vote

* fix: issue vote migration file

* fix: access creation in comments (#2013)

* dev: user timezone select option (#2002)

* fix: start date filter not working on the platform (#2007)

* feat: access selector for comment (#2012)

* dev: access specifier for comment

* chore: change access order

* style: revamp of the issue details sidebar (#2014)

* chore: update module status icons and colors (#2011)

* chore: update module status icons and colors

* refactor: import statements

* fix: add default alue to module status

* chore: track public board comments and reaction users for public deploy boards (#1972)

* chore: track project deploy board comment and reaction users for public deploy boards

* dev: remove tracking from project viewsets

* feat: user timezones (#2009)

* dev: user timezones

* feat: user timezones

* fix: user created by stats (#2016)

* fix: asset key validation (#1938)

* fix: asset key validation

* chore: asset key validation in user assets

---------

Co-authored-by: Bavisetti Narayan <narayan@Bavisettis-MacBook-Pro.local>

* dev: revamp peek overview (#2021)

* dev: mobx for issues store

* refactor: peek overview component

* chore: update open issue button

* fix: issue mutation after any crud action

* chore: remove peek overview from gantt

* chore: refactor code

* chore: tracking the history of issue reactions and votes. (#2020)

* chore: tracking the issues reaction and vote history

* fix: changed the keywords for vote and reaction

* chore: added validation

* dev: revamp publish project modal (#2022)

* dev: revamp publish project modal

* chore: sidebar dropdown text

* fix: bugs on the user profile page (#2018)

* chore: return issue votes in public issue list endpoint (#2026)

* style: list view

* [feat]: Tiptap table integration (#2008)

* added basic table support

* fixed table position at bottom

* fixed image node deletion logic's regression issue

* added compatible styles

* enabled slash commands

* disabled slash command and bubble menu's node selector for table cells

* added dropcursor support to type below the table/image

* blocked image uploads for handledrop and paste actions

* style: kanban view

* style: tiptap table (#2033)

* style: theming added

* chore: issue reactions and votes

---------

Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Bavisetti Narayan <narayan@Bavisettis-MacBook-Pro.local>
Co-authored-by: M. Palanikannan <73993394+Palanikannan1437@users.noreply.github.com>
This commit is contained in:
Aaryan Khandelwal 2023-08-31 23:24:03 +05:30 committed by GitHub
parent 1cd1f6e8c9
commit 6b4084287c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
131 changed files with 6147 additions and 1507 deletions

View File

@ -1,3 +1,3 @@
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
worker: celery -A plane worker -l info
beat: celery -A plane beat -l INFO

View File

@ -20,6 +20,7 @@ from .project import (
ProjectMemberLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
ProjectPublicMemberSerializer
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
@ -44,6 +45,7 @@ from .issue import (
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
IssuePublicSerializer,
)
from .module import (

View File

@ -113,7 +113,11 @@ class IssueCreateSerializer(BaseSerializer):
]
def validate(self, data):
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None):
if (
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
return data
@ -554,9 +558,7 @@ class CommentReactionSerializer(BaseSerializer):
read_only_fields = ["workspace", "project", "comment", "actor"]
class IssueVoteSerializer(BaseSerializer):
class Meta:
model = IssueVote
fields = ["issue", "vote", "workspace_id", "project_id", "actor"]
@ -569,7 +571,7 @@ class IssueCommentSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True)
is_member = serializers.BooleanField(read_only=True)
class Meta:
model = IssueComment
@ -582,7 +584,6 @@ class IssueCommentSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"access",
]
@ -676,6 +677,32 @@ class IssueLiteSerializer(BaseSerializer):
]
class IssuePublicSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")
issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True)
votes = IssueVoteSerializer(read_only=True, many=True)
class Meta:
model = Issue
fields = [
"id",
"name",
"description_html",
"sequence_id",
"state",
"state_detail",
"project",
"project_detail",
"workspace",
"priority",
"target_date",
"issue_reactions",
"votes",
]
read_only_fields = fields
class IssueSubscriberSerializer(BaseSerializer):
class Meta:
model = IssueSubscriber

View File

@ -15,6 +15,7 @@ from plane.db.models import (
ProjectIdentifier,
ProjectFavorite,
ProjectDeployBoard,
ProjectPublicMember,
)
@ -177,5 +178,17 @@ class ProjectDeployBoardSerializer(BaseSerializer):
fields = "__all__"
read_only_fields = [
"workspace",
"project" "anchor",
"project", "anchor",
]
class ProjectPublicMemberSerializer(BaseSerializer):
class Meta:
model = ProjectPublicMember
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"member",
]

View File

@ -164,16 +164,18 @@ from plane.api.views import (
# Notification
NotificationViewSet,
UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet,
## End Notification
# Public Boards
ProjectDeployBoardViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectIssuesPublicEndpoint,
ProjectDeployBoardPublicSettingsEndpoint,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
InboxIssuePublicViewSet,
IssueVotePublicViewSet,
WorkspaceProjectDeployBoardEndpoint,
IssueRetrievePublicEndpoint,
## End Public Boards
## Exporter
ExportIssuesEndpoint,
@ -235,7 +237,7 @@ urlpatterns = [
UpdateUserTourCompletedEndpoint.as_view(),
name="user-tour",
),
path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
path("users/workspaces/<str:slug>/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
# user workspaces
path(
"users/me/workspaces/",
@ -1494,6 +1496,15 @@ urlpatterns = [
UnreadNotificationEndpoint.as_view(),
name="unread-notifications",
),
path(
"workspaces/<str:slug>/users/notifications/mark-all-read/",
MarkAllReadNotificationViewSet.as_view(
{
"post": "create",
}
),
name="mark-all-read-notifications",
),
## End Notification
# Public Boards
path(
@ -1524,9 +1535,14 @@ urlpatterns = [
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/",
ProjectDeployBoardIssuesPublicEndpoint.as_view(),
ProjectIssuesPublicEndpoint.as_view(),
name="project-deploy-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/",
IssueRetrievePublicEndpoint.as_view(),
name="workspace-project-boards",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentPublicViewSet.as_view(

View File

@ -12,7 +12,6 @@ from .project import (
ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint,
ProjectFavoritesViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardViewSet,
ProjectDeployBoardPublicSettingsEndpoint,
ProjectMemberEndpoint,
@ -85,6 +84,8 @@ from .issue import (
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
IssueRetrievePublicEndpoint,
ProjectIssuesPublicEndpoint,
)
from .auth_extended import (
@ -162,7 +163,7 @@ from .analytic import (
DefaultAnalyticsEndpoint,
)
from .notification import NotificationViewSet, UnreadNotificationEndpoint
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
from .exporter import (
ExportIssuesEndpoint,

View File

@ -18,10 +18,21 @@ class FileAssetEndpoint(BaseAPIView):
"""
def get(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
files = FileAsset.objects.filter(asset=asset_key)
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
return Response(serializer.data)
try:
asset_key = str(workspace_id) + "/" + asset_key
files = FileAsset.objects.filter(asset=asset_key)
if files.exists():
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK)
else:
return Response({"error": "Asset key does not exist", "status": False}, 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,
)
def post(self, request, slug):
try:
@ -68,11 +79,16 @@ class UserAssetsEndpoint(BaseAPIView):
def get(self, request, asset_key):
try:
files = FileAsset.objects.filter(asset=asset_key, created_by=request.user)
serializer = FileAssetSerializer(files, context={"request": request})
return Response(serializer.data)
except FileAsset.DoesNotExist:
if files.exists():
serializer = FileAssetSerializer(files, context={"request": request})
return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK)
else:
return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "File Asset does not exist"}, status=status.HTTP_404_NOT_FOUND
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def post(self, request):

View File

@ -1,24 +1,41 @@
# Python imports
import zoneinfo
# Django imports
from django.urls import resolve
from django.conf import settings
from django.utils import timezone
# Third part imports
from rest_framework import status
from rest_framework.viewsets import ModelViewSet
from rest_framework.exceptions import APIException
from rest_framework.views import APIView
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.exceptions import NotFound
from sentry_sdk import capture_exception
from django_filters.rest_framework import DjangoFilterBackend
# Module imports
from plane.db.models import Workspace, Project
from plane.utils.paginator import BasePaginator
class BaseViewSet(ModelViewSet, BasePaginator):
class TimezoneMixin:
"""
This enables timezone conversion according
to the user set timezone
"""
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if request.user.is_authenticated:
timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone))
else:
timezone.deactivate()
class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
model = None
@ -67,7 +84,7 @@ class BaseViewSet(ModelViewSet, BasePaginator):
return self.kwargs.get("pk", None)
class BaseAPIView(APIView, BasePaginator):
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
permission_classes = [
IsAuthenticated,

View File

@ -28,6 +28,7 @@ from django.conf import settings
from rest_framework.response import Response
from rest_framework import status
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.permissions import AllowAny
from sentry_sdk import capture_exception
# Module imports
@ -49,6 +50,7 @@ from plane.api.serializers import (
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
IssuePublicSerializer,
)
from plane.api.permissions import (
WorkspaceEntityPermission,
@ -73,10 +75,12 @@ from plane.db.models import (
CommentReaction,
ProjectDeployBoard,
IssueVote,
ProjectPublicMember,
)
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.export_task import issue_export_task
class IssueViewSet(BaseViewSet):
@ -482,7 +486,7 @@ class IssueActivityEndpoint(BaseAPIView):
issue_activities = (
IssueActivity.objects.filter(issue_id=issue_id)
.filter(
~Q(field="comment"),
~Q(field__in=["comment", "vote", "reaction"]),
project__project_projectmember__member=self.request.user,
)
.select_related("actor", "workspace", "issue", "project")
@ -492,6 +496,12 @@ class IssueActivityEndpoint(BaseAPIView):
.filter(project__project_projectmember__member=self.request.user)
.order_by("created_at")
.select_related("actor", "issue", "project", "workspace")
.prefetch_related(
Prefetch(
"comment_reactions",
queryset=CommentReaction.objects.select_related("actor"),
)
)
)
issue_activities = IssueActivitySerializer(issue_activities, many=True).data
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
@ -588,6 +598,15 @@ class IssueCommentViewSet(BaseViewSet):
.select_related("project")
.select_related("workspace")
.select_related("issue")
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
member_id=self.request.user.id,
)
)
)
.distinct()
)
@ -769,7 +788,9 @@ class SubIssuesEndpoint(BaseAPIView):
.order_by("state_group")
)
result = {item["state_group"]: item["state_count"] for item in state_distribution}
result = {
item["state_group"]: item["state_count"] for item in state_distribution
}
serializer = IssueLiteSerializer(
sub_issues,
@ -1384,6 +1405,14 @@ class IssueReactionViewSet(BaseViewSet):
project_id=self.kwargs.get("project_id"),
actor=self.request.user,
)
issue_activity.delay(
type="issue_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
try:
@ -1394,6 +1423,19 @@ class IssueReactionViewSet(BaseViewSet):
reaction=reaction_code,
actor=request.user,
)
issue_activity.delay(
type="issue_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(issue_reaction.id),
}
),
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist:
@ -1434,6 +1476,14 @@ class CommentReactionViewSet(BaseViewSet):
comment_id=self.kwargs.get("comment_id"),
project_id=self.kwargs.get("project_id"),
)
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
try:
@ -1444,6 +1494,20 @@ class CommentReactionViewSet(BaseViewSet):
reaction=reaction_code,
actor=request.user,
)
issue_activity.delay(
type="comment_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(comment_reaction.id),
"comment_id": str(comment_id)
}
),
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist:
@ -1479,9 +1543,19 @@ class IssueCommentPublicViewSet(BaseViewSet):
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(access="EXTERNAL")
.select_related("project")
.select_related("workspace")
.select_related("issue")
.annotate(
is_member=Exists(
ProjectMember.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
member_id=self.request.user.id,
)
)
)
.distinct()
)
else:
@ -1499,21 +1573,13 @@ class IssueCommentPublicViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
access = (
"INTERNAL"
if ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists()
else "EXTERNAL"
)
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
issue_id=issue_id,
actor=request.user,
access=access,
access="EXTERNAL",
)
issue_activity.delay(
type="comment.activity.created",
@ -1523,6 +1589,16 @@ class IssueCommentPublicViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
)
if not ProjectMember.objects.filter(
project_id=project_id,
member=request.user,
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
member=request.user,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
@ -1567,7 +1643,8 @@ class IssueCommentPublicViewSet(BaseViewSet):
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
return Response(
{"error": "IssueComent Does not exists"},
status=status.HTTP_400_BAD_REQUEST,)
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id, pk):
try:
@ -1648,6 +1725,23 @@ class IssueReactionPublicViewSet(BaseViewSet):
serializer.save(
project_id=project_id, issue_id=issue_id, actor=request.user
)
if not ProjectMember.objects.filter(
project_id=project_id,
member=request.user,
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
member=request.user,
)
issue_activity.delay(
type="issue_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except ProjectDeployBoard.DoesNotExist:
@ -1679,6 +1773,19 @@ class IssueReactionPublicViewSet(BaseViewSet):
reaction=reaction_code,
actor=request.user,
)
issue_activity.delay(
type="issue_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(issue_reaction.id),
}
),
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist:
@ -1733,8 +1840,29 @@ class CommentReactionPublicViewSet(BaseViewSet):
serializer.save(
project_id=project_id, comment_id=comment_id, actor=request.user
)
if not ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists():
# Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
member=request.user,
)
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IssueComment.DoesNotExist:
return Response(
{"error": "Comment does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project board does not exist"},
@ -1765,6 +1893,20 @@ class CommentReactionPublicViewSet(BaseViewSet):
reaction=reaction_code,
actor=request.user,
)
issue_activity.delay(
type="comment_reaction.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"reaction": str(reaction_code),
"identifier": str(comment_reaction.id),
"comment_id": str(comment_id)
}
),
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist:
@ -1799,8 +1941,25 @@ class IssueVotePublicViewSet(BaseViewSet):
actor_id=request.user.id,
project_id=project_id,
issue_id=issue_id,
vote=request.data.get("vote", 1),
)
# Add the user for workspace tracking
if not ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists():
_ = ProjectPublicMember.objects.get_or_create(
project_id=project_id,
member=request.user,
)
issue_vote.vote = request.data.get("vote", 1)
issue_vote.save()
issue_activity.delay(
type="issue_vote.activity.created",
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
)
serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
@ -1818,6 +1977,19 @@ class IssueVotePublicViewSet(BaseViewSet):
issue_id=issue_id,
actor_id=request.user.id,
)
issue_activity.delay(
type="issue_vote.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"vote": str(issue_vote.vote),
"identifier": str(issue_vote.id),
}
),
)
issue_vote.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
@ -1827,3 +1999,175 @@ class IssueVotePublicViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
class IssueRetrievePublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id, issue_id):
try:
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=issue_id
)
serializer = IssuePublicSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
except Issue.DoesNotExist:
return Response(
{"error": "Issue Does not exist"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
print(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.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")
)
)
# 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 = IssuePublicSerializer(issue_queryset, many=True).data
states = State.objects.filter(
workspace__slug=slug, project_id=project_id
).values("name", "group", "color", "id")
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
issues = group_results(issues, group_by)
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -10,7 +10,13 @@ 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,
WorkspaceMember,
)
from plane.api.serializers import NotificationSerializer
@ -83,13 +89,17 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
# Created issues
if type == "created":
if WorkspaceMember.objects.filter(workspace__slug=slug, member=request.user, role__lt=15).exists():
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)
notifications = notifications.filter(
entity_identifier__in=issue_ids
)
# Pagination
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
@ -274,3 +284,80 @@ class UnreadNotificationEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class MarkAllReadNotificationViewSet(BaseViewSet):
def create(self, request, slug):
try:
snoozed = request.data.get("snoozed", False)
archived = request.data.get("archived", False)
type = request.data.get("type", "all")
notifications = (
Notification.objects.filter(
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
)
.select_related("workspace", "project", "triggered_by", "receiver")
.order_by("snoozed_till", "-created_at")
)
# Filter for snoozed notifications
if snoozed:
notifications = notifications.filter(
Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False)
)
else:
notifications = notifications.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
)
# Filter for archived or unarchive
if archived:
notifications = notifications.filter(archived_at__isnull=False)
else:
notifications = notifications.filter(archived_at__isnull=True)
# Subscribed issues
if type == "watching":
issue_ids = IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id
).values_list("issue_id", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
# Assigned Issues
if type == "assigned":
issue_ids = IssueAssignee.objects.filter(
workspace__slug=slug, assignee_id=request.user.id
).values_list("issue_id", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
# 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
)
updated_notifications = []
for notification in notifications:
notification.read_at = timezone.now()
updated_notifications.append(notification)
Notification.objects.bulk_update(
updated_notifications, ["read_at"], batch_size=100
)
return Response({"message": "Successful"}, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -1143,154 +1143,6 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
)
class ProjectDeployBoardIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.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")
)
)
# 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
states = State.objects.filter(
workspace__slug=slug, project_id=project_id
).values("name", "group", "color", "id")
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
issues = group_results(issues, group_by)
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
permission_classes = [AllowAny,]

View File

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

View File

@ -1100,7 +1100,6 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
created_issues = (
Issue.issue_objects.filter(
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
created_by_id=user_id,
)
@ -1198,6 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
projects = request.query_params.getlist("project", [])
queryset = IssueActivity.objects.filter(
~Q(field__in=["comment", "vote", "reaction"]),
workspace__slug=slug,
project__project_projectmember__member=request.user,
actor=user_id,

View File

@ -4,6 +4,7 @@ import io
import json
import boto3
import zipfile
from urllib.parse import urlparse, urlunparse
# Django imports
from django.conf import settings
@ -23,9 +24,11 @@ def dateTimeConverter(time):
if time:
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
def dateConverter(time):
if time:
return time.strftime("%a, %d %b %Y")
return time.strftime("%a, %d %b %Y")
def create_csv_file(data):
csv_buffer = io.StringIO()
@ -66,28 +69,53 @@ def create_zip_file(files):
def upload_to_s3(zip_file, workspace_id, token_id, slug):
s3 = boto3.client(
"s3",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
s3.upload_fileobj(
zip_file,
settings.AWS_S3_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
expires_in = 7 * 24 * 60 * 60
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
if settings.DOCKERIZED and settings.USE_MINIO:
s3 = boto3.client(
"s3",
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
s3.upload_fileobj(
zip_file,
settings.AWS_STORAGE_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
# Create the new url with updated domain and protocol
presigned_url = presigned_url.replace(
"http://plane-minio:9000/uploads/",
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/",
)
else:
s3 = boto3.client(
"s3",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
s3.upload_fileobj(
zip_file,
settings.AWS_S3_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
exporter_instance = ExporterHistory.objects.get(token=token_id)
@ -98,7 +126,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
else:
exporter_instance.status = "failed"
exporter_instance.save(update_fields=["status", "url","key"])
exporter_instance.save(update_fields=["status", "url", "key"])
def generate_table_row(issue):
@ -145,7 +173,7 @@ def generate_json_row(issue):
else "",
"Labels": issue["labels__name"],
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
@ -242,7 +270,9 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
workspace_issues = (
(
Issue.objects.filter(
workspace__id=workspace_id, project_id__in=project_ids
workspace__id=workspace_id,
project_id__in=project_ids,
project__project_projectmember__member=exporter_instance.initiated_by_id,
)
.select_related("project", "workspace", "state", "parent", "created_by")
.prefetch_related(
@ -275,7 +305,7 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
"labels__name",
)
)
.order_by("project__identifier","sequence_id")
.order_by("project__identifier", "sequence_id")
.distinct()
)
# CSV header
@ -338,7 +368,6 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
exporter_instance.status = "failed"
exporter_instance.reason = str(e)
exporter_instance.save(update_fields=["status", "reason"])
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)

View File

@ -21,18 +21,29 @@ def delete_old_s3_link():
expired_exporter_history = ExporterHistory.objects.filter(
Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8))
).values_list("key", "id")
s3 = boto3.client(
"s3",
region_name="ap-south-1",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
if settings.DOCKERIZED and settings.USE_MINIO:
s3 = boto3.client(
"s3",
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
else:
s3 = boto3.client(
"s3",
region_name="ap-south-1",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
for file_name, exporter_id in expired_exporter_history:
# Delete object from S3
if file_name:
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
if settings.DOCKERIZED and settings.USE_MINIO:
s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
else:
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
ExporterHistory.objects.filter(id=exporter_id).update(url=None)

View File

@ -24,6 +24,9 @@ from plane.db.models import (
IssueSubscriber,
Notification,
IssueAssignee,
IssueReaction,
CommentReaction,
IssueComment,
)
from plane.api.serializers import IssueActivitySerializer
@ -629,7 +632,7 @@ def update_issue_activity(
"parent": track_parent,
"priority": track_priority,
"state": track_state,
"description": track_description,
"description_html": track_description,
"target_date": track_target_date,
"start_date": track_start_date,
"labels_list": track_labels,
@ -1022,6 +1025,150 @@ def delete_attachment_activity(
)
)
def create_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
issue_reaction = IssueReaction.objects.filter(reaction=requested_data.get("reaction"), project=project, actor=actor).values_list('id', flat=True).first()
if issue_reaction is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="created",
old_value=None,
new_value=requested_data.get("reaction"),
field="reaction",
project=project,
workspace=project.workspace,
comment="added the reaction",
old_identifier=None,
new_identifier=issue_reaction,
)
)
def delete_issue_reaction_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
)
if current_instance and current_instance.get("reaction") is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="deleted",
old_value=current_instance.get("reaction"),
new_value=None,
field="reaction",
project=project,
workspace=project.workspace,
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
)
)
def create_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
comment_reaction_id, comment_id = CommentReaction.objects.filter(reaction=requested_data.get("reaction"), project=project, actor=actor).values_list('id', 'comment__id').first()
comment = IssueComment.objects.get(pk=comment_id,project=project)
if comment is not None and comment_reaction_id is not None and comment_id is not None:
issue_activities.append(
IssueActivity(
issue_id=comment.issue_id,
actor=actor,
verb="created",
old_value=None,
new_value=requested_data.get("reaction"),
field="reaction",
project=project,
workspace=project.workspace,
comment="added the reaction",
old_identifier=None,
new_identifier=comment_reaction_id,
)
)
def delete_comment_reaction_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
)
if current_instance and current_instance.get("reaction") is not None:
issue_id = IssueComment.objects.filter(pk=current_instance.get("comment_id"), project=project).values_list('issue_id', flat=True).first()
if issue_id is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="deleted",
old_value=current_instance.get("reaction"),
new_value=None,
field="reaction",
project=project,
workspace=project.workspace,
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
)
)
def create_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("vote") is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="created",
old_value=None,
new_value=requested_data.get("vote"),
field="vote",
project=project,
workspace=project.workspace,
comment="added the vote",
old_identifier=None,
new_identifier=None,
)
)
def delete_issue_vote_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
)
if current_instance and current_instance.get("vote") is not None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="deleted",
old_value=current_instance.get("vote"),
new_value=None,
field="vote",
project=project,
workspace=project.workspace,
comment="removed the vote",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
)
)
# Receive message from room group
@shared_task
@ -1045,6 +1192,12 @@ def issue_activity(
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
]:
issue = Issue.objects.filter(pk=issue_id).first()
@ -1080,6 +1233,12 @@ def issue_activity(
"link.activity.deleted": delete_link_activity,
"attachment.activity.created": create_attachment_activity,
"attachment.activity.deleted": delete_attachment_activity,
"issue_reaction.activity.created": create_issue_reaction_activity,
"issue_reaction.activity.deleted": delete_issue_reaction_activity,
"comment_reaction.activity.created": create_comment_reaction_activity,
"comment_reaction.activity.deleted": delete_comment_reaction_activity,
"issue_vote.activity.created": create_issue_vote_activity,
"issue_vote.activity.deleted": delete_issue_vote_activity,
}
func = ACTIVITY_MAPPER.get(type)
@ -1119,6 +1278,12 @@ def issue_activity(
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
]:
# Create Notifications
bulk_notifications = []

View File

@ -64,7 +64,7 @@ def archive_old_issues():
issues_to_update.append(issue)
# Bulk Update the issues and log the activity
Issue.objects.bulk_update(
updated_issues = Issue.objects.bulk_update(
issues_to_update, ["archived_at"], batch_size=100
)
[
@ -77,7 +77,7 @@ def archive_old_issues():
current_instance=None,
subscriber=False,
)
for issue in issues_to_update
for issue in updated_issues
]
return
except Exception as e:
@ -136,7 +136,7 @@ def close_old_issues():
issues_to_update.append(issue)
# Bulk Update the issues and log the activity
Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
[
issue_activity.delay(
type="issue.activity.updated",
@ -147,7 +147,7 @@ def close_old_issues():
current_instance=None,
subscriber=False,
)
for issue in issues_to_update
for issue in updated_issues
]
return
except Exception as e:

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.3 on 2023-08-29 07:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0041_cycle_sort_order_issuecomment_access_and_more'),
]
operations = [
migrations.AlterUniqueTogether(
name='issuevote',
unique_together=set(),
),
migrations.AlterField(
model_name='issuevote',
name='vote',
field=models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')], default=1),
),
migrations.AlterUniqueTogether(
name='issuevote',
unique_together={('issue', 'actor', 'vote')},
),
]

View File

@ -19,6 +19,7 @@ from .project import (
ProjectIdentifier,
ProjectFavorite,
ProjectDeployBoard,
ProjectPublicMember,
)
from .issue import (

View File

@ -476,10 +476,12 @@ class IssueVote(ProjectBaseModel):
choices=(
(-1, "DOWNVOTE"),
(1, "UPVOTE"),
)
),
default=1,
)
class Meta:
unique_together = ["issue", "actor"]
unique_together = ["issue", "actor", "vote"]
verbose_name = "Issue Vote"
verbose_name_plural = "Issue Votes"
db_table = "issue_votes"

View File

@ -254,3 +254,18 @@ class ProjectDeployBoard(ProjectBaseModel):
def __str__(self):
"""Return project and anchor"""
return f"{self.anchor} <{self.project.name}>"
class ProjectPublicMember(ProjectBaseModel):
member = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="public_project_members",
)
class Meta:
unique_together = ["project", "member"]
verbose_name = "Project Public Member"
verbose_name_plural = "Project Public Members"
db_table = "project_public_members"
ordering = ("-created_at",)

View File

@ -2,6 +2,7 @@
import uuid
import string
import random
import pytz
# Django imports
from django.db import models
@ -9,9 +10,6 @@ from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import AbstractBaseUser, UserManager, PermissionsMixin
from django.utils import timezone
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
# Third party imports
@ -66,7 +64,8 @@ class User(AbstractBaseUser, PermissionsMixin):
billing_address = models.JSONField(null=True)
has_billing_address = models.BooleanField(default=False)
user_timezone = models.CharField(max_length=255, default="Asia/Kolkata")
USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
user_timezone = models.CharField(max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES)
last_active = models.DateTimeField(default=timezone.now, null=True)
last_login_time = models.DateTimeField(null=True)

View File

@ -49,7 +49,7 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"crum.CurrentRequestUserMiddleware",
"django.middleware.gzip.GZipMiddleware",
]
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
@ -161,7 +161,7 @@ MEDIA_URL = "/media/"
LANGUAGE_CODE = "en-us"
TIME_ZONE = "Asia/Kolkata"
TIME_ZONE = "UTC"
USE_I18N = True

View File

@ -665,7 +665,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiscordIcon className="h-4 w-4" color="#6b7280" />
<DiscordIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Join our Discord
</div>
</Command.Item>

View File

@ -6,7 +6,6 @@ import { mutate } from "swr";
// components
import {
IssuePeekOverview,
ViewAssigneeSelect,
ViewDueDateSelect,
ViewEstimateSelect,
@ -76,9 +75,6 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
}) => {
const [isOpen, setIsOpen] = useState(false);
// issue peek overview
const [issuePeekOverview, setIssuePeekOverview] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
@ -161,6 +157,15 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
[workspaceSlug, projectId, cycleId, moduleId, viewId, params, user]
);
const openPeekOverview = () => {
const { query } = router;
router.push({
pathname: router.pathname,
query: { ...query, peekIssue: issue.id },
});
};
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
@ -183,15 +188,6 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
return (
<>
<IssuePeekOverview
handleDeleteIssue={() => handleDeleteIssue(issue)}
handleUpdateIssue={async (formData) => partialUpdateIssue(formData, issue)}
issue={issue}
isOpen={issuePeekOverview}
onClose={() => setIssuePeekOverview(false)}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={isNotAllowed}
/>
<div
className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max"
style={{ gridTemplateColumns }}
@ -280,7 +276,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
<button
type="button"
className="truncate text-custom-text-100 text-left cursor-pointer w-full text-[0.825rem]"
onClick={() => setIssuePeekOverview(true)}
onClick={openPeekOverview}
>
{issue.name}
</button>

View File

@ -6,6 +6,7 @@ import { useRouter } from "next/router";
// components
import { SpreadsheetColumns, SpreadsheetIssues } from "components/core";
import { CustomMenu, Spinner } from "components/ui";
import { IssuePeekOverview } from "components/issues";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
@ -38,7 +39,7 @@ export const SpreadsheetView: React.FC<Props> = ({
const type = cycleId ? "cycle" : moduleId ? "module" : "issue";
const { spreadsheetIssues } = useSpreadsheetIssuesView();
const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView();
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
@ -59,80 +60,88 @@ export const SpreadsheetView: React.FC<Props> = ({
.join(" ");
return (
<div className="h-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-100">
<div className="sticky z-[2] top-0 border-b border-custom-border-200 bg-custom-background-90 w-full min-w-max">
<SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} />
</div>
{spreadsheetIssues ? (
<div className="flex flex-col h-full w-full bg-custom-background-100 rounded-sm ">
{spreadsheetIssues.map((issue: IIssue, index) => (
<SpreadsheetIssues
key={`${issue.id}_${index}`}
index={index}
issue={issue}
expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleIssueAction={handleIssueAction}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
/>
))}
<div
className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max"
style={{ gridTemplateColumns }}
>
{type === "issue" ? (
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
) : (
!disableUserActions && (
<CustomMenu
className="sticky left-0 z-[1]"
customButton={
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full"
type="button"
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
}
position="left"
optionsClassName="left-5 !w-36"
noBorder
>
<CustomMenu.MenuItem
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)
)}
</div>
<>
<IssuePeekOverview
handleMutation={() => mutateIssues()}
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={disableUserActions}
/>
<div className="h-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-100">
<div className="sticky z-[2] top-0 border-b border-custom-border-200 bg-custom-background-90 w-full min-w-max">
<SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} />
</div>
) : (
<Spinner />
)}
</div>
{spreadsheetIssues ? (
<div className="flex flex-col h-full w-full bg-custom-background-100 rounded-sm ">
{spreadsheetIssues.map((issue: IIssue, index) => (
<SpreadsheetIssues
key={`${issue.id}_${index}`}
index={index}
issue={issue}
expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleIssueAction={handleIssueAction}
disableUserActions={disableUserActions}
user={user}
userAuth={userAuth}
/>
))}
<div
className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max"
style={{ gridTemplateColumns }}
>
{type === "issue" ? (
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
) : (
!disableUserActions && (
<CustomMenu
className="sticky left-0 z-[1]"
customButton={
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full"
type="button"
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
}
position="left"
optionsClassName="left-5 !w-36"
noBorder
>
<CustomMenu.MenuItem
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)
)}
</div>
</div>
) : (
<Spinner />
)}
</div>
</>
);
};

View File

@ -27,6 +27,7 @@ export * from "./started-state-icon";
export * from "./layer-diagonal-icon";
export * from "./lock-icon";
export * from "./menu-icon";
export * from "./module";
export * from "./pencil-scribble-icon";
export * from "./plus-icon";
export * from "./person-running-icon";

View File

@ -0,0 +1,57 @@
import React from "react";
type Props = {
width?: string;
height?: string;
className?: string;
color?: string;
};
export const ModuleBacklogIcon: React.FC<Props> = ({ width = "20", height = "20", className }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
className={className}
viewBox="0 0 247.63 247.6"
>
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path fill="#f6aa3e" d="M87.76,165.33a2.1,2.1,0,0,1-2.33,0Z" />
<path fill="#f5a839" d="M94.08,165.33a1.67,1.67,0,0,1-2,0Z" />
<path
fill="#a3a3a2"
d="M.29,115.46A130.18,130.18,0,0,1,2.05,101c.15-1,.53-1.37,1.62-1.15q7.78,1.64,15.6,3.12c1,.2,1.27.56,1.07,1.63a105.92,105.92,0,0,0-1.7,23.11,99.36,99.36,0,0,0,1.7,15.3c.2,1.05,0,1.44-1.06,1.64q-7.82,1.49-15.6,3.12c-1.22.25-1.5-.29-1.66-1.29C1.33,142,.64,137.63.34,133.16c0-.28-.05-.56-.34-.71v-.66c.36-.68.08-1.41.17-2.12A15,15,0,0,0,0,126.13v-4.66a17,17,0,0,0,.17-3.7A9.41,9.41,0,0,1,.29,115.46Z"
/>
<path
fill="#a3a3a2"
d="M132.14.29a130.64,130.64,0,0,1,14.48,1.76c1,.15,1.36.55,1.13,1.63q-1.62,7.79-3.11,15.6c-.2,1-.58,1.26-1.63,1.06a106.48,106.48,0,0,0-23-1.71,101.71,101.71,0,0,0-15.47,1.71c-1.08.21-1.42-.05-1.62-1.08-1-5.2-2-10.41-3.12-15.59-.23-1.1.17-1.47,1.15-1.62A137.72,137.72,0,0,1,115.46.28a5.78,5.78,0,0,1,1.66-.11h1.66A9,9,0,0,0,121.47,0h4.66a17,17,0,0,0,3.7.17A9.41,9.41,0,0,1,132.14.29Z"
/>
<path
fill="#a3a3a2"
d="M229,123.63a92.74,92.74,0,0,0-1.71-18.81c-.25-1.28.09-1.7,1.31-1.93q7.57-1.44,15.12-3c1.13-.24,1.66,0,1.88,1.22a133,133,0,0,1,2,26.28,141.92,141.92,0,0,1-2,19.29c-.19,1.08-.71,1.27-1.69,1.07q-7.71-1.58-15.45-3.07c-1.07-.21-1.36-.6-1.15-1.73A98.45,98.45,0,0,0,229,123.63Z"
/>
<path
fill="#a3a3a2"
d="M123.83,247.6a131.89,131.89,0,0,1-22.79-2c-1.14-.19-1.4-.68-1.18-1.76q1.61-7.71,3.09-15.45c.19-1,.45-1.34,1.6-1.12a105.9,105.9,0,0,0,23,1.72A101.84,101.84,0,0,0,143,227.26c1.05-.19,1.44,0,1.64,1.07q1.49,7.81,3.12,15.61c.22,1.08-.15,1.45-1.15,1.62A129.86,129.86,0,0,1,123.83,247.6Z"
/>
<path
fill="#a3a3a2"
d="M65.13,211.57c-.17.28-.37.62-.58.94-2.93,4.37-5.88,8.72-8.76,13.13-.6.91-1,1-1.92.4a126.44,126.44,0,0,1-32-32c-.8-1.15-.84-1.7.45-2.52,4.35-2.77,8.61-5.66,12.86-8.56.9-.62,1.33-.5,1.94.38a103.22,103.22,0,0,0,27.2,27.21C65.1,211.15,65.14,211.21,65.13,211.57Z"
/>
<path
fill="#a3a3a2"
d="M192.79,226.56c-.51-.06-.61-.4-.79-.67-3.05-4.55-6.08-9.12-9.17-13.65-.6-.89-.16-1.22.5-1.66a103.37,103.37,0,0,0,16.56-14.05,93.49,93.49,0,0,0,10.53-13c.68-1,1.15-1.13,2.18-.42,4.32,3,8.7,5.91,13.09,8.81.83.54,1,.94.38,1.8a125.4,125.4,0,0,1-32,32C193.65,226,193.18,226.31,192.79,226.56Z"
/>
<path
fill="#a3a3a2"
d="M36,65.13c-.28-.18-.62-.37-.94-.59-4.37-2.92-8.72-5.88-13.12-8.75-.95-.62-1-1-.36-1.91A127.22,127.22,0,0,1,53.69,21.72c1.07-.74,1.56-.65,2.27.46,2.79,4.32,5.67,8.6,8.57,12.85.63.91.68,1.38-.32,2.07A105,105,0,0,0,37,64.29C36.43,65.13,36.4,65.14,36,65.13Z"
/>
<path
fill="#a3a3a2"
d="M226.53,54.77c.07.52-.35.64-.66.85-4.56,3.05-9.12,6.08-13.65,9.15-.76.51-1.12.3-1.57-.38a97.64,97.64,0,0,0-11.79-14.34,97,97,0,0,0-15.37-12.87c-1.05-.7-1.09-1.18-.4-2.19,3-4.33,5.91-8.7,8.8-13.09.57-.86,1-.91,1.79-.32A126.12,126.12,0,0,1,225.92,53.8C226.14,54.11,226.33,54.45,226.53,54.77Z"
/>
</g>
</g>
</svg>
);

View File

@ -0,0 +1,35 @@
import React from "react";
type Props = {
width?: string;
height?: string;
className?: string;
color?: string;
};
export const ModuleCancelledIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_4052_100277)">
<path
d="M8 8.84L10.58 11.42C10.7 11.54 10.84 11.6 11 11.6C11.16 11.6 11.3 11.54 11.42 11.42C11.54 11.3 11.6 11.16 11.6 11C11.6 10.84 11.54 10.7 11.42 10.58L8.84 8L11.42 5.42C11.54 5.3 11.6 5.16 11.6 5C11.6 4.84 11.54 4.7 11.42 4.58C11.3 4.46 11.16 4.4 11 4.4C10.84 4.4 10.7 4.46 10.58 4.58L8 7.16L5.42 4.58C5.3 4.46 5.16 4.4 5 4.4C4.84 4.4 4.7 4.46 4.58 4.58C4.46 4.7 4.4 4.84 4.4 5C4.4 5.16 4.46 5.3 4.58 5.42L7.16 8L4.58 10.58C4.46 10.7 4.4 10.84 4.4 11C4.4 11.16 4.46 11.3 4.58 11.42C4.7 11.54 4.84 11.6 5 11.6C5.16 11.6 5.3 11.54 5.42 11.42L8 8.84ZM8 16C6.90667 16 5.87333 15.79 4.9 15.37C3.92667 14.95 3.07667 14.3767 2.35 13.65C1.62333 12.9233 1.05 12.0733 0.63 11.1C0.21 10.1267 0 9.09333 0 8C0 6.89333 0.21 5.85333 0.63 4.88C1.05 3.90667 1.62333 3.06 2.35 2.34C3.07667 1.62 3.92667 1.05 4.9 0.63C5.87333 0.21 6.90667 0 8 0C9.10667 0 10.1467 0.21 11.12 0.63C12.0933 1.05 12.94 1.62 13.66 2.34C14.38 3.06 14.95 3.90667 15.37 4.88C15.79 5.85333 16 6.89333 16 8C16 9.09333 15.79 10.1267 15.37 11.1C14.95 12.0733 14.38 12.9233 13.66 13.65C12.94 14.3767 12.0933 14.95 11.12 15.37C10.1467 15.79 9.10667 16 8 16ZM8 14.8C9.89333 14.8 11.5 14.1367 12.82 12.81C14.14 11.4833 14.8 9.88 14.8 8C14.8 6.10667 14.14 4.5 12.82 3.18C11.5 1.86 9.89333 1.2 8 1.2C6.12 1.2 4.51667 1.86 3.19 3.18C1.86333 4.5 1.2 6.10667 1.2 8C1.2 9.88 1.86333 11.4833 3.19 12.81C4.51667 14.1367 6.12 14.8 8 14.8Z"
fill="#ef4444"
/>
</g>
<defs>
<clipPath id="clip0_4052_100277">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);

View File

@ -0,0 +1,28 @@
import React from "react";
type Props = {
width?: string;
height?: string;
className?: string;
color?: string;
};
export const ModuleCompletedIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.80486 9.80731L4.84856 7.85103C4.73197 7.73443 4.58542 7.67478 4.4089 7.67208C4.23238 7.66937 4.08312 7.72902 3.96113 7.85103C3.83913 7.97302 3.77814 8.12093 3.77814 8.29474C3.77814 8.46855 3.83913 8.61645 3.96113 8.73844L6.27206 11.0494C6.42428 11.2016 6.60188 11.2777 6.80486 11.2777C7.00782 11.2777 7.18541 11.2016 7.33764 11.0494L12.0227 6.36435C12.1393 6.24776 12.1989 6.10121 12.2016 5.92469C12.2043 5.74817 12.1447 5.59891 12.0227 5.47692C11.9007 5.35493 11.7528 5.29393 11.579 5.29393C11.4051 5.29393 11.2572 5.35493 11.1353 5.47692L6.80486 9.80731ZM8.00141 16C6.89494 16 5.85491 15.79 4.88132 15.3701C3.90772 14.9502 3.06082 14.3803 2.34064 13.6604C1.62044 12.9405 1.05028 12.094 0.63017 11.1208C0.210057 10.1477 0 9.10788 0 8.00141C0 6.89494 0.209966 5.85491 0.629896 4.88132C1.04983 3.90772 1.61972 3.06082 2.33958 2.34064C3.05946 1.62044 3.90598 1.05028 4.87915 0.630171C5.8523 0.210058 6.89212 0 7.99859 0C9.10506 0 10.1451 0.209966 11.1187 0.629897C12.0923 1.04983 12.9392 1.61972 13.6594 2.33959C14.3796 3.05946 14.9497 3.90598 15.3698 4.87915C15.7899 5.8523 16 6.89212 16 7.99859C16 9.10506 15.79 10.1451 15.3701 11.1187C14.9502 12.0923 14.3803 12.9392 13.6604 13.6594C12.9405 14.3796 12.094 14.9497 11.1208 15.3698C10.1477 15.7899 9.10788 16 8.00141 16ZM8 14.7369C9.88071 14.7369 11.4737 14.0842 12.779 12.779C14.0842 11.4737 14.7369 9.88071 14.7369 8C14.7369 6.11929 14.0842 4.52631 12.779 3.22104C11.4737 1.91577 9.88071 1.26314 8 1.26314C6.11929 1.26314 4.52631 1.91577 3.22104 3.22104C1.91577 4.52631 1.26314 6.11929 1.26314 8C1.26314 9.88071 1.91577 11.4737 3.22104 12.779C4.52631 14.0842 6.11929 14.7369 8 14.7369Z"
fill="#16a34a"
/>
</svg>
);

View File

@ -0,0 +1,71 @@
import React from "react";
type Props = {
width?: string;
height?: string;
className?: string;
color?: string;
};
export const ModuleInProgressIcon: React.FC<Props> = ({
width = "20",
height = "20",
className,
}) => (
<svg
width={width}
height={height}
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 234.83 234.82"
>
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path fill="#f7b964" d="M0,111.14c.63.7.21,1.53.3,2.29-.07.26-.17.28-.3,0Z" />
<path fill="#f6ab3e" d="M0,119.46a3.11,3.11,0,0,1,.3,2q-.19.33-.3,0Z" />
<path
fill="#facf96"
d="M.27,123.16c0,.66.38,1.38-.27,2v-2C.13,122.89.22,122.91.27,123.16Z"
/>
<path fill="#f5a939" d="M0,113.47l.3,0a2.39,2.39,0,0,1-.3,1.71Z" />
<path fill="#f8ba67" d="M.27,123.16a.63.63,0,0,1-.27,0v-1.66l.3,0Z" />
<path
fill="#f39e1f"
d="M234.58,106.92a72,72,0,0,0-.65-8.42,117.08,117.08,0,0,0-13.46-38.74,118.87,118.87,0,0,0-31.73-36.49A115,115,0,0,0,151.17,4.14,83.24,83.24,0,0,0,134.28.58c-2.94-.24-5.89-.22-8.83-.58h-4a2.66,2.66,0,0,1-2,0h-4.32a3.45,3.45,0,0,1-2.33,0h-3.66c-.51.33-1.08.14-1.62.16A87.24,87.24,0,0,0,90,2.35,118.53,118.53,0,0,0,23.16,46,115.24,115.24,0,0,0,4.29,83,85.41,85.41,0,0,0,.6,100.15c-.26,3-.22,6-.6,9v2a6.63,6.63,0,0,1,.17,2.26c-.08.58.17,1.19-.17,1.74v4.32c.35.66.08,1.37.17,2.05v1.57c-.09.68.18,1.39-.17,2v.67c.3.39.14.85.16,1.28.2,3.18.22,6.38.66,9.53a101.21,101.21,0,0,0,4.27,17.76A118.17,118.17,0,0,0,99,234a100.25,100.25,0,0,0,11.37.65,167.86,167.86,0,0,0,23.84-.54,100.39,100.39,0,0,0,23.35-5.72,117.87,117.87,0,0,0,39.67-24.08,117.77,117.77,0,0,0,33.27-53.2,85.63,85.63,0,0,0,3.71-17.37A212.22,212.22,0,0,0,234.58,106.92ZM117.31,217a99.63,99.63,0,0,1-99.7-100.05c0-54.91,44.8-99.35,100.09-99.33,54.89,0,99.32,44.83,99.29,100.14C217,172.43,172.21,217,117.31,217Z"
/>
<path
fill="#f39e1f"
d="M117.33,44a84.49,84.49,0,0,1,12.9,1.15c1.09.19,1.37.56,1.15,1.6q-1.51,7.41-2.94,14.82c-.16.82-.45,1.11-1.33.95a53.31,53.31,0,0,0-19.67,0c-.77.14-1.11-.06-1.26-.83q-1.47-7.59-3-15.16c-.2-1,.21-1.19,1.08-1.35A80.7,80.7,0,0,1,117.33,44Z"
/>
<path
fill="#f39e1f"
d="M44,117.2a80.88,80.88,0,0,1,1.17-12.9c.18-1,.49-1.3,1.49-1.1q7.49,1.53,15,3c.89.18,1,.59.85,1.39a53.54,53.54,0,0,0,0,19.51c.15.83,0,1.2-.88,1.36-5,1-10,2-15,3-.85.17-1.25,0-1.43-1A82.68,82.68,0,0,1,44,117.2Z"
/>
<path
fill="#f39e1f"
d="M190.64,117.39a80.88,80.88,0,0,1-1.17,12.9c-.18,1-.46,1.32-1.48,1.11q-7.49-1.53-15-3c-.88-.17-1-.57-.86-1.38a53.54,53.54,0,0,0,0-19.51c-.18-1,.16-1.23,1-1.39q7.33-1.41,14.66-2.91c1-.21,1.46-.09,1.65,1.08A86.71,86.71,0,0,1,190.64,117.39Z"
/>
<path
fill="#f39e1f"
d="M117.28,190.64a83.24,83.24,0,0,1-12.9-1.15c-1.07-.19-1.38-.53-1.16-1.6q1.52-7.39,2.94-14.82c.16-.8.43-1.12,1.32-.95a53.31,53.31,0,0,0,19.67,0c.92-.17,1.14.2,1.29,1q1.44,7.42,2.95,14.82c.19.95,0,1.35-1,1.54A83,83,0,0,1,117.28,190.64Z"
/>
<path
fill="#f39e1f"
d="M70.7,86.15,70,85.74c-4.23-2.84-8.45-5.69-12.71-8.49-.76-.5-.93-.86-.36-1.67A75.59,75.59,0,0,1,75.41,57.11c.85-.6,1.29-.66,1.93.33,2.71,4.18,5.5,8.3,8.3,12.42.53.78.62,1.18-.28,1.81A54.6,54.6,0,0,0,71.68,85.32C71.07,86.18,71.05,86.17,70.7,86.15Z"
/>
<path
fill="#f39e1f"
d="M178,76.28c.05.58-.38.7-.69.9-4.27,2.87-8.55,5.72-12.82,8.6-.6.41-1,.47-1.44-.23a54.76,54.76,0,0,0-14-14c-.66-.46-.69-.8-.25-1.45q4.33-6.39,8.59-12.83c.47-.72.82-.84,1.56-.33A74.64,74.64,0,0,1,177.53,75.5C177.72,75.77,177.89,76.06,178,76.28Z"
/>
<path
fill="#f39e1f"
d="M70.68,148.46c.48-.11.59.27.77.52A55.65,55.65,0,0,0,85.59,163.1c.58.4.66.72.26,1.32q-4.38,6.47-8.69,13c-.41.63-.74.81-1.43.32a74.65,74.65,0,0,1-18.8-18.8c-.42-.61-.48-1,.23-1.46,4.34-2.87,8.65-5.78,13-8.67C70.32,148.67,70.52,148.56,70.68,148.46Z"
/>
<path
fill="#f39e1f"
d="M158.24,178.06c-.56-.08-.67-.5-.87-.8-2.84-4.23-5.66-8.47-8.52-12.68-.47-.69-.47-1,.29-1.56A54.46,54.46,0,0,0,163,149.11c.53-.77.9-.7,1.57-.24q6.33,4.29,12.7,8.49c.81.53.86.91.32,1.68a74.06,74.06,0,0,1-18.46,18.45C158.84,177.71,158.5,177.9,158.24,178.06Z"
/>
</g>
</g>
</svg>
);

View File

@ -0,0 +1,7 @@
export * from "./backlog";
export * from "./cancelled";
export * from "./completed";
export * from "./in-progress";
export * from "./module-status-icon";
export * from "./paused";
export * from "./planned";

View File

@ -0,0 +1,37 @@
// icons
import {
ModuleBacklogIcon,
ModuleCancelledIcon,
ModuleCompletedIcon,
ModuleInProgressIcon,
ModulePausedIcon,
ModulePlannedIcon,
} from "components/icons";
// types
import { TModuleStatus } from "types";
type Props = {
status: TModuleStatus;
className?: string;
height?: string;
width?: string;
};
export const ModuleStatusIcon: React.FC<Props> = ({
status,
className,
height = "12px",
width = "12px",
}) => {
if (status === "backlog")
return <ModuleBacklogIcon className={className} height={height} width={width} />;
else if (status === "cancelled")
return <ModuleCancelledIcon className={className} height={height} width={width} />;
else if (status === "completed")
return <ModuleCompletedIcon className={className} height={height} width={width} />;
else if (status === "in-progress")
return <ModuleInProgressIcon className={className} height={height} width={width} />;
else if (status === "paused")
return <ModulePausedIcon className={className} height={height} width={width} />;
else return <ModulePlannedIcon className={className} height={height} width={width} />;
};

View File

@ -0,0 +1,31 @@
import React from "react";
type Props = {
width?: string;
height?: string;
className?: string;
color?: string;
};
export const ModulePausedIcon: React.FC<Props> = ({ width = "20", height = "20", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_4052_100275)">
<path
d="M6.4435 10.34C6.6145 10.34 6.75667 10.2825 6.87 10.1675C6.98333 10.0525 7.04 9.91 7.04 9.74V6.24C7.04 6.07 6.98217 5.9275 6.8665 5.8125C6.75082 5.6975 6.60749 5.64 6.4365 5.64C6.2655 5.64 6.12333 5.6975 6.01 5.8125C5.89667 5.9275 5.84 6.07 5.84 6.24V9.74C5.84 9.91 5.89783 10.0525 6.0135 10.1675C6.12918 10.2825 6.27251 10.34 6.4435 10.34ZM9.5635 10.34C9.7345 10.34 9.87667 10.2825 9.99 10.1675C10.1033 10.0525 10.16 9.91 10.16 9.74V6.24C10.16 6.07 10.1022 5.9275 9.9865 5.8125C9.87082 5.6975 9.72749 5.64 9.5565 5.64C9.3855 5.64 9.24333 5.6975 9.13 5.8125C9.01667 5.9275 8.96 6.07 8.96 6.24V9.74C8.96 9.91 9.01783 10.0525 9.1335 10.1675C9.24918 10.2825 9.39251 10.34 9.5635 10.34ZM8 16C6.89333 16 5.85333 15.79 4.88 15.37C3.90667 14.95 3.06 14.38 2.34 13.66C1.62 12.94 1.05 12.0933 0.63 11.12C0.21 10.1467 0 9.10667 0 8C0 7.54667 0.0366667 7.09993 0.11 6.6598C0.183333 6.21965 0.293333 5.78639 0.44 5.36C0.493333 5.21333 0.593333 5.11667 0.74 5.07C0.886667 5.02333 1.02667 5.04199 1.16 5.12596C1.30285 5.20993 1.40523 5.33327 1.46714 5.49596C1.52905 5.65865 1.54 5.82 1.5 5.98C1.42 6.31333 1.35 6.64765 1.29 6.98294C1.23 7.31823 1.2 7.65725 1.2 8C1.2 9.89833 1.85875 11.5063 3.17624 12.8238C4.49375 14.1413 6.10167 14.8 8 14.8C9.89833 14.8 11.5063 14.1413 12.8238 12.8238C14.1413 11.5063 14.8 9.89833 14.8 8C14.8 6.10167 14.1413 4.49375 12.8238 3.17624C11.5063 1.85875 9.89833 1.2 8 1.2C7.63235 1.2 7.26852 1.22667 6.90852 1.28C6.54852 1.33333 6.19235 1.41333 5.84 1.52C5.68 1.57333 5.52 1.56667 5.36 1.5C5.2 1.43333 5.08667 1.32667 5.02 1.18C4.95333 1.03333 4.96 0.886667 5.04 0.74C5.12 0.593333 5.23333 0.493333 5.38 0.44C5.79333 0.306667 6.21333 0.2 6.64 0.12C7.06667 0.04 7.49333 0 7.92 0C9.02667 0 10.07 0.21 11.05 0.63C12.03 1.05 12.8863 1.62 13.6189 2.34C14.3516 3.06 14.9316 3.90667 15.3589 4.88C15.7863 5.85333 16 6.89333 16 8C16 9.10667 15.79 10.1467 15.37 11.12C14.95 12.0933 14.38 12.94 13.66 13.66C12.94 14.38 12.0933 14.95 11.12 15.37C10.1467 15.79 9.10667 16 8 16ZM2.65764 3.62C2.37921 3.62 2.14333 3.52255 1.95 3.32764C1.75667 3.13275 1.66 2.89608 1.66 2.61764C1.66 2.33921 1.75745 2.10333 1.95236 1.91C2.14725 1.71667 2.38392 1.62 2.66236 1.62C2.94079 1.62 3.17667 1.71745 3.37 1.91236C3.56333 2.10725 3.66 2.34392 3.66 2.62236C3.66 2.90079 3.56255 3.13667 3.36764 3.33C3.17275 3.52333 2.93608 3.62 2.65764 3.62Z"
fill="#525252"
/>
</g>
<defs>
<clipPath id="clip0_4052_100275">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);

View File

@ -0,0 +1,24 @@
import React from "react";
type Props = {
width?: string;
height?: string;
className?: string;
color?: string;
};
export const ModulePlannedIcon: React.FC<Props> = ({ width = "20", height = "20", className }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.57177 7.43329L11.3665 10.228C11.4883 10.3498 11.5441 10.4809 11.5339 10.6213C11.5238 10.7617 11.4578 10.8928 11.336 11.0146C11.2142 11.1364 11.0794 11.1973 10.9317 11.1973C10.784 11.1973 10.6492 11.1364 10.5274 11.0146L7.64476 8.12349C7.57709 8.05582 7.52408 7.98139 7.48574 7.90018C7.4474 7.81898 7.42823 7.72538 7.42823 7.61936V3.51362C7.42823 3.35574 7.48405 3.22097 7.5957 3.10932C7.70734 2.99768 7.84211 2.94185 8 2.94185C8.15789 2.94185 8.29266 2.99768 8.4043 3.10932C8.51595 3.22097 8.57177 3.35574 8.57177 3.51362V7.43329ZM0.806954 11.4933C0.573486 11.04 0.390212 10.5655 0.257131 10.0698C0.124064 9.57411 0.0383541 9.07477 0 8.57177H1.15709C1.18077 8.98793 1.24646 9.38746 1.35418 9.77036C1.46188 10.1533 1.60427 10.5297 1.78133 10.8996L0.806954 11.4933ZM0.021992 7.42823C0.0603462 6.92523 0.143806 6.4273 0.272371 5.93444C0.400937 5.4416 0.579131 4.96567 0.806954 4.50665L1.80333 5.07842C1.62062 5.44834 1.47315 5.82983 1.36093 6.22287C1.24872 6.61591 1.18077 7.0177 1.15709 7.42823H0.021992ZM3.52381 14.6128C3.10877 14.3286 2.71855 14.0103 2.35315 13.6578C1.98774 13.3054 1.66294 12.9217 1.37872 12.5067L2.3751 11.9044C2.5939 12.2507 2.84879 12.5656 3.13976 12.8492C3.43073 13.1329 3.74934 13.3914 4.09558 13.6249L3.52381 14.6128ZM2.3751 4.09558L1.37872 3.52381C1.66294 3.09411 1.98408 2.70163 2.34215 2.34637C2.70023 1.99112 3.09411 1.67139 3.52381 1.38719L4.09558 2.3751C3.75837 2.60856 3.44568 2.86571 3.15753 3.14653C2.86937 3.42736 2.60856 3.7437 2.3751 4.09558ZM7.42823 16C6.92523 15.9616 6.4273 15.8745 5.93444 15.7386C5.4416 15.6027 4.96567 15.4209 4.50665 15.193L5.07842 14.2187C5.44834 14.4014 5.82983 14.5452 6.22287 14.6501C6.61591 14.7549 7.0177 14.8192 7.42823 14.8429V16ZM5.10042 1.80333L4.50665 0.806954C4.96567 0.579131 5.4416 0.397272 5.93444 0.261376C6.4273 0.125479 6.92523 0.0383541 7.42823 0V1.15709C7.0177 1.18077 6.61957 1.24872 6.23386 1.36093C5.84815 1.47315 5.47034 1.62062 5.10042 1.80333ZM8.57177 16V14.8429C8.9823 14.8192 9.38409 14.7549 9.77713 14.6501C10.1702 14.5452 10.5517 14.4014 10.9216 14.2187L11.4933 15.193C11.0343 15.4209 10.5584 15.6027 10.0656 15.7386C9.5727 15.8745 9.07477 15.9616 8.57177 16ZM10.9216 1.78133C10.5517 1.59862 10.1702 1.45482 9.77713 1.34994C9.38409 1.24505 8.9823 1.18077 8.57177 1.15709V0C9.07477 0.0383541 9.5727 0.125479 10.0656 0.261376C10.5584 0.397272 11.0343 0.579131 11.4933 0.806954L10.9216 1.78133ZM12.4762 14.6128L11.9044 13.6469C12.2563 13.4134 12.5726 13.149 12.8535 12.8535C13.1343 12.558 13.3914 12.2416 13.6249 11.9044L14.6128 12.4982C14.3286 12.9132 14.0052 13.297 13.6426 13.6494C13.28 14.0018 12.8912 14.323 12.4762 14.6128ZM13.6249 4.08713C13.3914 3.74991 13.1306 3.43862 12.8425 3.15328C12.5543 2.86796 12.2416 2.60856 11.9044 2.3751L12.4762 1.38719C12.8912 1.67703 13.28 1.99817 13.6426 2.3506C14.0052 2.70304 14.3314 3.08678 14.6213 3.50181L13.6249 4.08713ZM14.8429 7.42823C14.8192 7.0177 14.7535 6.61957 14.6458 6.23386C14.5381 5.84815 14.3929 5.46752 14.2102 5.09197L15.193 4.50665C15.4265 4.96003 15.6098 5.43314 15.7429 5.926C15.8759 6.41884 15.9616 6.91958 16 7.42823H14.8429ZM15.193 11.4933L14.2187 10.9216C14.4014 10.5517 14.5452 10.1702 14.6501 9.77713C14.7549 9.38409 14.8192 8.9823 14.8429 8.57177H16C15.9616 9.07477 15.8745 9.5727 15.7386 10.0656C15.6027 10.5584 15.4209 11.0343 15.193 11.4933Z"
fill="#3f76ff"
/>
</svg>
);

View File

@ -3,38 +3,55 @@ import { useRouter } from "next/router";
// react-hook-form
import { useForm, Controller } from "react-hook-form";
// components
import { SecondaryButton } from "components/ui";
import { TipTapEditor } from "components/tiptap";
// ui
import { Icon, SecondaryButton, Tooltip } from "components/ui";
// types
import type { IIssueComment } from "types";
const defaultValues: Partial<IIssueComment> = {
comment_json: "",
access: "INTERNAL",
comment_html: "",
};
type Props = {
disabled?: boolean;
onSubmit: (data: IIssueComment) => Promise<void>;
showAccessSpecifier?: boolean;
};
export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => {
const {
control,
formState: { isSubmitting },
handleSubmit,
reset,
setValue,
watch,
} = useForm<IIssueComment>({ defaultValues });
const commentAccess = [
{
icon: "lock",
key: "INTERNAL",
label: "Private",
},
{
icon: "public",
key: "EXTERNAL",
label: "Public",
},
];
export const AddComment: React.FC<Props> = ({
disabled = false,
onSubmit,
showAccessSpecifier = false,
}) => {
const editorRef = React.useRef<any>(null);
const router = useRouter();
const { workspaceSlug } = router.query;
const {
control,
formState: { isSubmitting },
handleSubmit,
reset,
} = useForm<IIssueComment>({ defaultValues });
const handleAddComment = async (formData: IIssueComment) => {
if (!formData.comment_html || !formData.comment_json || isSubmitting) return;
if (!formData.comment_html || isSubmitting) return;
await onSubmit(formData).then(() => {
reset(defaultValues);
@ -45,30 +62,55 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => {
return (
<div>
<form onSubmit={handleSubmit(handleAddComment)}>
<div className="issue-comments-section">
<Controller
name="comment_html"
control={control}
render={({ field: { value, onChange } }) => (
<TipTapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={
!value ||
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
? watch("comment_html")
: value
}
customClassName="p-3 min-h-[50px] shadow-sm"
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
setValue("comment_json", comment_json);
}}
/>
<div>
<div className="relative">
{showAccessSpecifier && (
<div className="absolute bottom-2 left-3 z-[1]">
<Controller
control={control}
name="access"
render={({ field: { onChange, value } }) => (
<div className="flex border border-custom-border-300 divide-x divide-custom-border-300 rounded overflow-hidden">
{commentAccess.map((access) => (
<Tooltip key={access.key} tooltipContent={access.label}>
<button
type="button"
onClick={() => onChange(access.key)}
className={`grid place-items-center p-1 hover:bg-custom-background-80 ${
value === access.key ? "bg-custom-background-80" : ""
}`}
>
<Icon
iconName={access.icon}
className={`w-4 h-4 -mt-1 ${
value === access.key
? "!text-custom-text-100"
: "!text-custom-text-400"
}`}
/>
</button>
</Tooltip>
))}
</div>
)}
/>
</div>
)}
/>
<Controller
name="comment_html"
control={control}
render={({ field: { value, onChange } }) => (
<TipTapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={!value || value === "" ? "<p></p>" : value}
customClassName="p-3 min-h-[100px] shadow-sm"
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => onChange(comment_html)}
/>
)}
/>
</div>
<SecondaryButton type="submit" disabled={isSubmitting || disabled} className="mt-2">
{isSubmitting ? "Adding..." : "Comment"}

View File

@ -15,7 +15,7 @@ export const IssueGanttBlock = ({ data }: { data: IIssue }) => {
return (
<div
className="flex items-center relative h-full w-full rounded"
className="flex items-center relative h-full w-full rounded cursor-pointer"
style={{ backgroundColor: data?.state_detail?.color }}
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)}
>
@ -49,7 +49,7 @@ export const IssueGanttSidebarBlock = ({ data }: { data: IIssue }) => {
return (
<div
className="relative w-full flex items-center gap-2 h-full"
className="relative w-full flex items-center gap-2 h-full cursor-pointer"
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)}
>
{getStateGroupIcon(data?.state_detail?.group, "14", "14", data?.state_detail?.color)}

View File

@ -8,6 +8,7 @@ import issuesService from "services/issues.service";
// hooks
import useUserAuth from "hooks/use-user-auth";
import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
// contexts
import { useProjectMyMembership } from "contexts/project-member.context";
// components
@ -49,6 +50,8 @@ export const IssueMainContent: React.FC<Props> = ({
const { user } = useUserAuth();
const { memberRole } = useProjectMyMembership();
const { projectDetails } = useProjectDetails();
const { data: siblingIssues } = useSWR(
workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null,
workspaceSlug && projectId && issueDetails?.parent
@ -220,7 +223,11 @@ export const IssueMainContent: React.FC<Props> = ({
handleCommentUpdate={handleCommentUpdate}
handleCommentDelete={handleCommentDelete}
/>
<AddComment onSubmit={handleAddComment} disabled={uneditable} />
<AddComment
onSubmit={handleAddComment}
disabled={uneditable}
showAccessSpecifier={projectDetails && projectDetails.is_deployed}
/>
</div>
</>
);

View File

@ -1,3 +1,4 @@
// components
import {
PeekOverviewHeader,
PeekOverviewIssueActivity,
@ -5,13 +6,16 @@ import {
PeekOverviewIssueProperties,
TPeekOverviewModes,
} from "components/issues";
// ui
import { Loader } from "components/ui";
// types
import { IIssue } from "types";
type Props = {
handleClose: () => void;
handleDeleteIssue: () => void;
handleUpdateIssue: (issue: Partial<IIssue>) => Promise<void>;
issue: IIssue;
handleUpdateIssue: (formData: Partial<IIssue>) => Promise<void>;
issue: IIssue | undefined;
mode: TPeekOverviewModes;
readOnly: boolean;
setMode: (mode: TPeekOverviewModes) => void;
@ -40,39 +44,59 @@ export const FullScreenPeekView: React.FC<Props> = ({
workspaceSlug={workspaceSlug}
/>
</div>
<div className="h-full w-full px-6 overflow-y-auto">
{/* issue title and description */}
<div className="w-full">
<PeekOverviewIssueDetails
handleUpdateIssue={handleUpdateIssue}
issue={issue}
readOnly={readOnly}
workspaceSlug={workspaceSlug}
/>
{issue ? (
<div className="h-full w-full px-6 overflow-y-auto">
{/* issue title and description */}
<div className="w-full">
<PeekOverviewIssueDetails
handleUpdateIssue={handleUpdateIssue}
issue={issue}
readOnly={readOnly}
workspaceSlug={workspaceSlug}
/>
</div>
{/* divider */}
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
{/* issue activity/comments */}
<div className="w-full pb-5">
<PeekOverviewIssueActivity
workspaceSlug={workspaceSlug}
issue={issue}
readOnly={readOnly}
/>
</div>
</div>
{/* divider */}
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
{/* issue activity/comments */}
<div className="w-full">
<PeekOverviewIssueActivity
workspaceSlug={workspaceSlug}
issue={issue}
readOnly={readOnly}
/>
</div>
</div>
) : (
<Loader className="px-6">
<Loader.Item height="30px" />
<div className="space-y-2 mt-3">
<Loader.Item height="20px" width="70%" />
<Loader.Item height="20px" width="60%" />
<Loader.Item height="20px" width="60%" />
</div>
</Loader>
)}
</div>
<div className="col-span-3 h-full w-full overflow-y-auto">
{/* issue properties */}
<div className="w-full px-6 py-5">
<PeekOverviewIssueProperties
handleDeleteIssue={handleDeleteIssue}
issue={issue}
mode="full"
onChange={handleUpdateIssue}
readOnly={readOnly}
workspaceSlug={workspaceSlug}
/>
{issue ? (
<PeekOverviewIssueProperties
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode="full"
readOnly={readOnly}
workspaceSlug={workspaceSlug}
/>
) : (
<Loader className="mt-11 space-y-4">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
)}
</div>
</div>
</div>

View File

@ -1,18 +1,21 @@
import Link from "next/link";
// hooks
import useToast from "hooks/use-toast";
// ui
import { CustomSelect, Icon } from "components/ui";
// icons
import { East, OpenInFull } from "@mui/icons-material";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IIssue } from "types";
import { TPeekOverviewModes } from "./layout";
import { ArrowRightAlt, CloseFullscreen, East, OpenInFull } from "@mui/icons-material";
type Props = {
handleClose: () => void;
handleDeleteIssue: () => void;
issue: IIssue;
issue: IIssue | undefined;
mode: TPeekOverviewModes;
setMode: (mode: TPeekOverviewModes) => void;
workspaceSlug: string;
@ -47,12 +50,9 @@ export const PeekOverviewHeader: React.FC<Props> = ({
const { setToastAlert } = useToast();
const handleCopyLink = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
const urlToCopy = window.location.href;
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`
).then(() => {
copyTextToClipboard(urlToCopy).then(() => {
setToastAlert({
type: "success",
title: "Link copied!",
@ -73,23 +73,15 @@ export const PeekOverviewHeader: React.FC<Props> = ({
/>
</button>
)}
{mode === "modal" || mode === "full" ? (
<button type="button" onClick={() => setMode("side")}>
<CloseFullscreen
sx={{
fontSize: "14px",
}}
/>
</button>
) : (
<button type="button" onClick={() => setMode("modal")}>
<Link href={`/${workspaceSlug}/projects/${issue?.project}/issues/${issue?.id}`}>
<a>
<OpenInFull
sx={{
fontSize: "14px",
}}
/>
</button>
)}
</a>
</Link>
<CustomSelect
value={mode}
onChange={(val: TPeekOverviewModes) => setMode(val)}
@ -119,7 +111,7 @@ export const PeekOverviewHeader: React.FC<Props> = ({
</CustomSelect>
</div>
{(mode === "side" || mode === "modal") && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-shrink-0">
<button type="button" onClick={handleCopyLink} className="-rotate-45">
<Icon iconName="link" />
</button>

View File

@ -1,6 +1,11 @@
// mobx
import { observer } from "mobx-react-lite";
// headless ui
import { Disclosure } from "@headlessui/react";
import { getStateGroupIcon } from "components/icons";
// hooks
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// components
import {
SidebarAssigneeSelect,
@ -9,27 +14,27 @@ import {
SidebarStateSelect,
TPeekOverviewModes,
} from "components/issues";
// icons
// ui
import { CustomDatePicker, Icon } from "components/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
import useToast from "hooks/use-toast";
// types
import { IIssue } from "types";
type Props = {
handleDeleteIssue: () => void;
handleUpdateIssue: (formData: Partial<IIssue>) => Promise<void>;
issue: IIssue;
mode: TPeekOverviewModes;
onChange: (issueProperty: Partial<IIssue>) => void;
readOnly: boolean;
workspaceSlug: string;
};
export const PeekOverviewIssueProperties: React.FC<Props> = ({
handleDeleteIssue,
handleUpdateIssue,
issue,
mode,
onChange,
readOnly,
workspaceSlug,
}) => {
@ -86,7 +91,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
<div className="w-3/4">
<SidebarStateSelect
value={issue.state}
onChange={(val: string) => onChange({ state: val })}
onChange={(val: string) => handleUpdateIssue({ state: val })}
disabled={readOnly}
/>
</div>
@ -99,7 +104,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
<div className="w-3/4">
<SidebarAssigneeSelect
value={issue.assignees_list}
onChange={(val: string[]) => onChange({ assignees_list: val })}
onChange={(val: string[]) => handleUpdateIssue({ assignees_list: val })}
disabled={readOnly}
/>
</div>
@ -112,7 +117,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
<div className="w-3/4">
<SidebarPrioritySelect
value={issue.priority}
onChange={(val: string) => onChange({ priority: val })}
onChange={(val: string) => handleUpdateIssue({ priority: val })}
disabled={readOnly}
/>
</div>
@ -128,7 +133,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
placeholder="Start date"
value={issue.start_date}
onChange={(val) =>
onChange({
handleUpdateIssue({
start_date: val,
})
}
@ -153,7 +158,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
placeholder="Due date"
value={issue.target_date}
onChange={(val) =>
onChange({
handleUpdateIssue({
target_date: val,
})
}
@ -175,7 +180,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({
<div className="w-3/4">
<SidebarEstimateSelect
value={issue.estimate_point}
onChange={(val: number | null) => onChange({ estimate_point: val })}
onChange={(val: number | null) =>handleUpdateIssue({ estimate_point: val })}
disabled={readOnly}
/>
</div>

View File

@ -1,107 +1,183 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// hooks
import useUser from "hooks/use-user";
// components
import { FullScreenPeekView, SidePeekView } from "components/issues";
// types
import { IIssue } from "types";
type Props = {
handleDeleteIssue: () => void;
handleUpdateIssue: (issue: Partial<IIssue>) => Promise<void>;
issue: IIssue | null;
isOpen: boolean;
onClose: () => void;
workspaceSlug: string;
handleMutation: () => void;
projectId: string;
readOnly: boolean;
workspaceSlug: string;
};
export type TPeekOverviewModes = "side" | "modal" | "full";
export const IssuePeekOverview: React.FC<Props> = ({
handleDeleteIssue,
handleUpdateIssue,
issue,
isOpen,
onClose,
workspaceSlug,
readOnly,
}) => {
const [peekOverviewMode, setPeekOverviewMode] = useState<TPeekOverviewModes>("side");
export const IssuePeekOverview: React.FC<Props> = observer(
({ handleMutation, projectId, readOnly, workspaceSlug }) => {
const [isSidePeekOpen, setIsSidePeekOpen] = useState(false);
const [isModalPeekOpen, setIsModalPeekOpen] = useState(false);
const [peekOverviewMode, setPeekOverviewMode] = useState<TPeekOverviewModes>("side");
const handleClose = () => {
onClose();
setPeekOverviewMode("side");
};
const router = useRouter();
const { peekIssue } = router.query;
if (!issue || !isOpen) return null;
const { issues: issuesStore } = useMobxStore();
const { deleteIssue, getIssueById, issues, updateIssue } = issuesStore;
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
{/* add backdrop conditionally */}
{(peekOverviewMode === "modal" || peekOverviewMode === "full") && (
<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-20 overflow-y-auto">
<div className="relative h-full w-full">
const issue = issues[peekIssue?.toString() ?? ""];
const { user } = useUser();
const handleClose = () => {
const { query } = router;
delete query.peekIssue;
router.push({
pathname: router.pathname,
query: { ...query },
});
};
const handleUpdateIssue = async (formData: Partial<IIssue>) => {
if (!issue || !user) return;
await updateIssue(workspaceSlug, projectId, issue.id, formData, user);
handleMutation();
};
const handleDeleteIssue = async () => {
if (!issue || !user) return;
await deleteIssue(workspaceSlug, projectId, issue.id, user);
handleMutation();
handleClose();
};
useEffect(() => {
if (!peekIssue) return;
getIssueById(workspaceSlug, projectId, peekIssue.toString());
}, [getIssueById, peekIssue, projectId, workspaceSlug]);
useEffect(() => {
if (peekIssue) {
if (peekOverviewMode === "side") {
setIsSidePeekOpen(true);
setIsModalPeekOpen(false);
} else {
setIsModalPeekOpen(true);
setIsSidePeekOpen(false);
}
} else {
setIsSidePeekOpen(false);
setIsModalPeekOpen(false);
}
}, [peekIssue, peekOverviewMode]);
return (
<>
<Transition.Root appear show={isSidePeekOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="relative h-full w-full">
<Transition.Child
as={React.Fragment}
enter="transition-transform duration-300"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition-transform duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<Dialog.Panel className="absolute z-20 bg-custom-background-100 top-0 right-0 h-full w-1/2 shadow-custom-shadow-md">
<SidePeekView
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={peekOverviewMode}
readOnly={readOnly}
setMode={(mode) => setPeekOverviewMode(mode)}
workspaceSlug={workspaceSlug}
/>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
<Transition.Root appear show={isModalPeekOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<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"
enterFrom="opacity-0"
enterTo="opacity-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"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Panel
className={`absolute z-20 bg-custom-background-100 ${
peekOverviewMode === "side"
? "top-0 right-0 h-full w-1/2 shadow-custom-shadow-md"
: peekOverviewMode === "modal"
? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[70%] w-3/5 rounded-lg shadow-custom-shadow-xl"
: "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[95%] w-[95%] rounded-lg shadow-custom-shadow-xl"
}`}
>
{(peekOverviewMode === "side" || peekOverviewMode === "modal") && (
<SidePeekView
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={peekOverviewMode}
readOnly={readOnly}
setMode={(mode) => setPeekOverviewMode(mode)}
workspaceSlug={workspaceSlug}
/>
)}
{peekOverviewMode === "full" && (
<FullScreenPeekView
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={peekOverviewMode}
readOnly={readOnly}
setMode={(mode) => setPeekOverviewMode(mode)}
workspaceSlug={workspaceSlug}
/>
)}
</Dialog.Panel>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="relative h-full w-full">
<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"
>
<Dialog.Panel
className={`absolute z-20 bg-custom-background-100 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg shadow-custom-shadow-xl transition-all duration-300 ${
peekOverviewMode === "modal" ? "h-[70%] w-3/5" : "h-[95%] w-[95%]"
}`}
>
{peekOverviewMode === "modal" && (
<SidePeekView
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={peekOverviewMode}
readOnly={readOnly}
setMode={(mode) => setPeekOverviewMode(mode)}
workspaceSlug={workspaceSlug}
/>
)}
{peekOverviewMode === "full" && (
<FullScreenPeekView
handleClose={handleClose}
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={peekOverviewMode}
readOnly={readOnly}
setMode={(mode) => setPeekOverviewMode(mode)}
workspaceSlug={workspaceSlug}
/>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</>
);
}
);

View File

@ -1,3 +1,4 @@
// components
import {
PeekOverviewHeader,
PeekOverviewIssueActivity,
@ -5,13 +6,16 @@ import {
PeekOverviewIssueProperties,
TPeekOverviewModes,
} from "components/issues";
// ui
import { Loader } from "components/ui";
// types
import { IIssue } from "types";
type Props = {
handleClose: () => void;
handleDeleteIssue: () => void;
handleUpdateIssue: (issue: Partial<IIssue>) => Promise<void>;
issue: IIssue;
handleUpdateIssue: (formData: Partial<IIssue>) => Promise<void>;
issue: IIssue | undefined;
mode: TPeekOverviewModes;
readOnly: boolean;
setMode: (mode: TPeekOverviewModes) => void;
@ -39,37 +43,50 @@ export const SidePeekView: React.FC<Props> = ({
workspaceSlug={workspaceSlug}
/>
</div>
<div className="h-full w-full px-6 overflow-y-auto">
{/* issue title and description */}
<div className="w-full">
<PeekOverviewIssueDetails
handleUpdateIssue={handleUpdateIssue}
issue={issue}
readOnly={readOnly}
workspaceSlug={workspaceSlug}
/>
{issue ? (
<div className="h-full w-full px-6 overflow-y-auto">
{/* issue title and description */}
<div className="w-full">
<PeekOverviewIssueDetails
handleUpdateIssue={handleUpdateIssue}
issue={issue}
readOnly={readOnly}
workspaceSlug={workspaceSlug}
/>
</div>
{/* issue properties */}
<div className="w-full mt-10">
<PeekOverviewIssueProperties
handleDeleteIssue={handleDeleteIssue}
handleUpdateIssue={handleUpdateIssue}
issue={issue}
mode={mode}
readOnly={readOnly}
workspaceSlug={workspaceSlug}
/>
</div>
{/* divider */}
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
{/* issue activity/comments */}
<div className="w-full pb-5">
{issue && (
<PeekOverviewIssueActivity
workspaceSlug={workspaceSlug}
issue={issue}
readOnly={readOnly}
/>
)}
</div>
</div>
{/* issue properties */}
<div className="w-full mt-10">
<PeekOverviewIssueProperties
handleDeleteIssue={handleDeleteIssue}
issue={issue}
mode={mode}
onChange={handleUpdateIssue}
readOnly={readOnly}
workspaceSlug={workspaceSlug}
/>
</div>
{/* divider */}
<div className="h-[1] w-full border-t border-custom-border-200 my-5" />
{/* issue activity/comments */}
<div className="w-full pb-5">
<PeekOverviewIssueActivity
workspaceSlug={workspaceSlug}
issue={issue}
readOnly={readOnly}
/>
</div>
</div>
) : (
<Loader className="px-6">
<Loader.Item height="30px" />
<div className="space-y-2 mt-3">
<Loader.Item height="20px" width="70%" />
<Loader.Item height="20px" width="60%" />
<Loader.Item height="20px" width="60%" />
</div>
</Loader>
)}
</div>
);

View File

@ -48,10 +48,10 @@ export const SidebarAssigneeSelect: React.FC<Props> = ({ value, onChange, disabl
{value && value.length > 0 && Array.isArray(value) ? (
<div className="-my-0.5 flex items-center gap-2">
<AssigneesList userIds={value} length={3} showLength={false} />
<span className="text-custom-text-100 text-sm">{value.length} Assignees</span>
<span className="text-custom-text-100 text-xs">{value.length} Assignees</span>
</div>
) : (
<button type="button" className="bg-custom-background-80 px-2.5 py-0.5 text-sm rounded">
<button type="button" className="bg-custom-background-80 px-2.5 py-0.5 text-xs rounded">
No assignees
</button>
)}

View File

@ -18,7 +18,6 @@ type Props = {
issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>;
userAuth: UserAuth;
disabled?: boolean;
};
@ -26,7 +25,6 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
issueId,
submitChanges,
watch,
userAuth,
disabled = false,
}) => {
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
@ -73,8 +71,6 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
handleClose();
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<>
<ExistingIssuesListModal
@ -128,11 +124,11 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
</div>
<button
type="button"
className={`flex w-full text-custom-text-200 ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
} items-center justify-between gap-1 rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm duration-300 focus:outline-none`}
className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
}`}
onClick={() => setIsBlockedModalOpen(true)}
disabled={isNotAllowed}
disabled={disabled}
>
Select issues
</button>

View File

@ -18,7 +18,6 @@ type Props = {
issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>;
userAuth: UserAuth;
disabled?: boolean;
};
@ -26,7 +25,6 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
issueId,
submitChanges,
watch,
userAuth,
disabled = false,
}) => {
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
@ -73,8 +71,6 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
handleClose();
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<>
<ExistingIssuesListModal
@ -130,11 +126,11 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
</div>
<button
type="button"
className={`flex w-full text-custom-text-200 ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
} items-center justify-between gap-1 rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm duration-300 focus:outline-none`}
className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
}`}
onClick={() => setIsBlockerModalOpen(true)}
disabled={isNotAllowed}
disabled={disabled}
>
Select issues
</button>

View File

@ -11,24 +11,20 @@ import cyclesService from "services/cycles.service";
import { Spinner, CustomSelect, Tooltip } from "components/ui";
// helper
import { truncateText } from "helpers/string.helper";
// icons
import { ContrastIcon } from "components/icons";
// types
import { ICycle, IIssue, UserAuth } from "types";
import { ICycle, IIssue } from "types";
// fetch-keys
import { CYCLE_ISSUES, INCOMPLETE_CYCLES_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
type Props = {
issueDetail: IIssue | undefined;
handleCycleChange: (cycle: ICycle) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarCycleSelect: React.FC<Props> = ({
issueDetail,
handleCycleChange,
userAuth,
disabled = false,
}) => {
const router = useRouter();
@ -63,59 +59,56 @@ export const SidebarCycleSelect: React.FC<Props> = ({
const issueCycle = issueDetail?.issue_cycle;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<ContrastIcon className="h-4 w-4 flex-shrink-0" />
<p>Cycle</p>
</div>
<div className="space-y-1 sm:basis-1/2">
<CustomSelect
label={
<Tooltip
position="left"
tooltipContent={`${issueCycle ? issueCycle.cycle_detail.name : "No cycle"}`}
>
<span className="w-full max-w-[125px] truncate text-left sm:block">
<span className={`${issueCycle ? "text-custom-text-100" : "text-custom-text-200"}`}>
{issueCycle ? truncateText(issueCycle.cycle_detail.name, 15) : "No cycle"}
</span>
</span>
</Tooltip>
}
value={issueCycle ? issueCycle.cycle_detail.id : null}
onChange={(value: any) => {
!value
? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "")
: handleCycleChange(incompleteCycles?.find((c) => c.id === value) as ICycle);
}}
width="w-full"
position="right"
maxHeight="rg"
disabled={isNotAllowed}
<CustomSelect
customButton={
<Tooltip
position="left"
tooltipContent={`${issueCycle ? issueCycle.cycle_detail.name : "No cycle"}`}
>
{incompleteCycles ? (
incompleteCycles.length > 0 ? (
<>
{incompleteCycles.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
<Tooltip position="left-bottom" tooltipContent={option.name}>
<span className="w-full truncate">{truncateText(option.name, 25)}</span>
</Tooltip>
</CustomSelect.Option>
))}
<CustomSelect.Option value={null}>None</CustomSelect.Option>
</>
) : (
<div className="text-center">No cycles found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
</div>
</div>
<button
type="button"
className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 w-full flex ${
disabled ? "cursor-not-allowed" : ""
}`}
>
<span
className={`truncate ${issueCycle ? "text-custom-text-100" : "text-custom-text-200"}`}
>
{issueCycle ? issueCycle.cycle_detail.name : "No cycle"}
</span>
</button>
</Tooltip>
}
value={issueCycle ? issueCycle.cycle_detail.id : null}
onChange={(value: any) => {
!value
? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "")
: handleCycleChange(incompleteCycles?.find((c) => c.id === value) as ICycle);
}}
width="w-full"
position="right"
maxHeight="rg"
disabled={disabled}
>
{incompleteCycles ? (
incompleteCycles.length > 0 ? (
<>
{incompleteCycles.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
<Tooltip position="left-bottom" tooltipContent={option.name}>
<span className="w-full truncate">{truncateText(option.name, 25)}</span>
</Tooltip>
</CustomSelect.Option>
))}
<CustomSelect.Option value={null}>None</CustomSelect.Option>
</>
) : (
<div className="text-center">No cycles found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
);
};

View File

@ -14,9 +14,7 @@ type Props = {
};
export const SidebarEstimateSelect: React.FC<Props> = ({ value, onChange, disabled = false }) => {
const { isEstimateActive, estimatePoints } = useEstimateOption();
if (!isEstimateActive) return null;
const { estimatePoints } = useEstimateOption();
return (
<CustomSelect

View File

@ -10,24 +10,20 @@ import modulesService from "services/modules.service";
import { Spinner, CustomSelect, Tooltip } from "components/ui";
// helper
import { truncateText } from "helpers/string.helper";
// icons
import { RectangleGroupIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IModule, UserAuth } from "types";
import { IIssue, IModule } from "types";
// fetch-keys
import { ISSUE_DETAILS, MODULE_ISSUES, MODULE_LIST } from "constants/fetch-keys";
type Props = {
issueDetail: IIssue | undefined;
handleModuleChange: (module: IModule) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarModuleSelect: React.FC<Props> = ({
issueDetail,
handleModuleChange,
userAuth,
disabled = false,
}) => {
const router = useRouter();
@ -57,66 +53,60 @@ export const SidebarModuleSelect: React.FC<Props> = ({
const issueModule = issueDetail?.issue_module;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<RectangleGroupIcon className="h-4 w-4 flex-shrink-0" />
<p>Module</p>
</div>
<div className="space-y-1 sm:basis-1/2">
<CustomSelect
label={
<Tooltip
position="left"
tooltipContent={`${
modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module"
<CustomSelect
customButton={
<Tooltip
position="left"
tooltipContent={`${
modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module"
}`}
>
<button
type="button"
className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 w-full flex ${
disabled ? "cursor-not-allowed" : ""
}`}
>
<span
className={`truncate ${
issueModule ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<span className="w-full max-w-[125px] truncate text-left sm:block">
<span
className={`${issueModule ? "text-custom-text-100" : "text-custom-text-200"}`}
>
{truncateText(
`${modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module"}`,
15
)}
</span>
</span>
</Tooltip>
}
value={issueModule ? issueModule.module_detail?.id : null}
onChange={(value: any) => {
!value
? removeIssueFromModule(issueModule?.id ?? "", issueModule?.module ?? "")
: handleModuleChange(modules?.find((m) => m.id === value) as IModule);
}}
width="w-full"
position="right"
maxHeight="rg"
disabled={isNotAllowed}
>
{modules ? (
modules.length > 0 ? (
<>
{modules.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
<Tooltip position="left-bottom" tooltipContent={option.name}>
<span className="w-full truncate">{truncateText(option.name, 25)}</span>
</Tooltip>
</CustomSelect.Option>
))}
<CustomSelect.Option value={null}>None</CustomSelect.Option>
</>
) : (
<div className="text-center">No modules found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
</div>
</div>
{modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module"}
</span>
</button>
</Tooltip>
}
value={issueModule ? issueModule.module_detail?.id : null}
onChange={(value: any) => {
!value
? removeIssueFromModule(issueModule?.id ?? "", issueModule?.module ?? "")
: handleModuleChange(modules?.find((m) => m.id === value) as IModule);
}}
width="w-full"
position="right"
maxHeight="rg"
disabled={disabled}
>
{modules ? (
modules.length > 0 ? (
<>
{modules.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
<Tooltip position="left-bottom" tooltipContent={option.name}>
<span className="w-full truncate">{truncateText(option.name, 25)}</span>
</Tooltip>
</CustomSelect.Option>
))}
<CustomSelect.Option value={null}>None</CustomSelect.Option>
</>
) : (
<div className="text-center">No modules found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
);
};

View File

@ -2,8 +2,6 @@ import React, { useState } from "react";
import { useRouter } from "next/router";
// icons
import { UserIcon } from "@heroicons/react/24/outline";
// components
import { ParentIssuesListModal } from "components/issues";
// types
@ -12,14 +10,12 @@ import { IIssue, ISearchIssueResponse, UserAuth } from "types";
type Props = {
onChange: (value: string) => void;
issueDetails: IIssue | undefined;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarParentSelect: React.FC<Props> = ({
onChange,
issueDetails,
userAuth,
disabled = false,
}) => {
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
@ -28,42 +24,34 @@ export const SidebarParentSelect: React.FC<Props> = ({
const router = useRouter();
const { projectId, issueId } = router.query;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<UserIcon className="h-4 w-4 flex-shrink-0" />
<p>Parent</p>
</div>
<div className="sm:basis-1/2">
<ParentIssuesListModal
isOpen={isParentModalOpen}
handleClose={() => setIsParentModalOpen(false)}
onChange={(issue) => {
onChange(issue.id);
setSelectedParentIssue(issue);
}}
issueId={issueId as string}
projectId={projectId as string}
/>
<button
type="button"
className={`flex w-full ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
} items-center justify-between gap-1 rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm duration-300 focus:outline-none`}
onClick={() => setIsParentModalOpen(true)}
disabled={isNotAllowed}
>
{selectedParentIssue && issueDetails?.parent ? (
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
) : !selectedParentIssue && issueDetails?.parent ? (
`${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}`
) : (
<span className="text-custom-text-200">Select issue</span>
)}
</button>
</div>
</div>
<>
<ParentIssuesListModal
isOpen={isParentModalOpen}
handleClose={() => setIsParentModalOpen(false)}
onChange={(issue) => {
onChange(issue.id);
setSelectedParentIssue(issue);
}}
issueId={issueId as string}
projectId={projectId as string}
/>
<button
type="button"
className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 ${
disabled ? "cursor-not-allowed" : "cursor-pointer "
}`}
onClick={() => setIsParentModalOpen(true)}
disabled={disabled}
>
{selectedParentIssue && issueDetails?.parent ? (
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
) : !selectedParentIssue && issueDetails?.parent ? (
`${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}`
) : (
<span className="text-custom-text-200">Select issue</span>
)}
</button>
</>
);
};

View File

@ -18,7 +18,7 @@ export const SidebarPrioritySelect: React.FC<Props> = ({ value, onChange, disabl
customButton={
<button
type="button"
className={`flex items-center gap-1.5 text-left text-sm capitalize rounded px-2.5 py-0.5 ${
className={`flex items-center gap-1.5 text-left text-xs capitalize rounded px-2.5 py-0.5 ${
value === "urgent"
? "border-red-500/20 bg-red-500/20 text-red-500"
: value === "high"

View File

@ -39,7 +39,7 @@ export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, disabled
return (
<CustomSelect
customButton={
<button type="button" className="bg-custom-background-80 text-sm rounded px-2.5 py-0.5">
<button type="button" className="bg-custom-background-80 text-xs rounded px-2.5 py-0.5">
{selectedState ? (
<div className="flex items-center gap-1.5 text-left text-custom-text-100">
{getStateGroupIcon(

View File

@ -10,6 +10,7 @@ import { Controller, UseFormWatch } from "react-hook-form";
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
import useUserIssueNotificationSubscription from "hooks/use-issue-notification-subscription";
import useEstimateOption from "hooks/use-estimate-option";
// services
import issuesService from "services/issues.service";
import modulesService from "services/modules.service";
@ -42,6 +43,8 @@ import {
ChartBarIcon,
UserGroupIcon,
PlayIcon,
UserIcon,
RectangleGroupIcon,
} from "@heroicons/react/24/outline";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
@ -49,6 +52,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types";
// fetch-keys
import { ISSUE_DETAILS } from "constants/fetch-keys";
import { ContrastIcon } from "components/icons";
type Props = {
control: any;
@ -93,6 +97,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
const { user } = useUserAuth();
const { isEstimateActive } = useEstimateOption();
const { loading, handleSubscribe, handleUnsubscribe, subscribed } =
useUserIssueNotificationSubscription(workspaceSlug, projectId, issueId);
@ -403,22 +409,51 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
</div>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && (
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) &&
isEstimateActive && (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<PlayIcon className="h-4 w-4 flex-shrink-0 -rotate-90" />
<p>Estimate</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="estimate_point"
render={({ field: { value } }) => (
<SidebarEstimateSelect
value={value}
onChange={(val: number | null) =>
submitChanges({ estimate_point: val })
}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/>
)}
/>
</div>
</div>
)}
</div>
)}
{showSecondSection && (
<div className="py-1">
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<PlayIcon className="h-4 w-4 flex-shrink-0 -rotate-90" />
<p>Estimate</p>
<UserIcon className="h-4 w-4 flex-shrink-0" />
<p>Parent</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="estimate_point"
render={({ field: { value } }) => (
<SidebarEstimateSelect
value={value}
onChange={(val: number | null) =>
submitChanges({ estimate_point: val })
}
name="parent"
render={({ field: { onChange } }) => (
<SidebarParentSelect
onChange={(val: string) => {
submitChanges({ parent: val });
onChange(val);
}}
issueDetails={issueDetail}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/>
)}
@ -426,34 +461,12 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
</div>
</div>
)}
</div>
)}
{showSecondSection && (
<div className="py-1">
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
<Controller
control={control}
name="parent"
render={({ field: { onChange } }) => (
<SidebarParentSelect
onChange={(val: string) => {
submitChanges({ parent: val });
onChange(val);
}}
issueDetails={issueDetail}
userAuth={memberRole}
disabled={uneditable}
/>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
<SidebarBlockerSelect
issueId={issueId as string}
submitChanges={submitChanges}
watch={watchIssue}
userAuth={memberRole}
disabled={uneditable}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
@ -461,8 +474,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
issueId={issueId as string}
submitChanges={submitChanges}
watch={watchIssue}
userAuth={memberRole}
disabled={uneditable}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && (
@ -484,8 +496,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
start_date: val,
})
}
className="bg-custom-background-100"
wrapperClassName="w-full"
className="bg-custom-background-80 border-none"
maxDate={maxDate ?? undefined}
disabled={isNotAllowed || uneditable}
/>
@ -513,8 +524,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
target_date: val,
})
}
className="bg-custom-background-100"
wrapperClassName="w-full"
className="bg-custom-background-80 border-none"
minDate={minDate ?? undefined}
disabled={isNotAllowed || uneditable}
/>
@ -528,20 +538,34 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
{showThirdSection && (
<div className="py-1">
{(fieldsToShow.includes("all") || fieldsToShow.includes("cycle")) && (
<SidebarCycleSelect
issueDetail={issueDetail}
handleCycleChange={handleCycleChange}
userAuth={memberRole}
disabled={uneditable}
/>
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
<ContrastIcon className="h-4 w-4 flex-shrink-0" />
<p>Cycle</p>
</div>
<div className="space-y-1 sm:w-1/2">
<SidebarCycleSelect
issueDetail={issueDetail}
handleCycleChange={handleCycleChange}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/>
</div>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && (
<SidebarModuleSelect
issueDetail={issueDetail}
handleModuleChange={handleModuleChange}
userAuth={memberRole}
disabled={uneditable}
/>
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
<RectangleGroupIcon className="h-4 w-4 flex-shrink-0" />
<p>Module</p>
</div>
<div className="space-y-1 sm:w-1/2">
<SidebarModuleSelect
issueDetail={issueDetail}
handleModuleChange={handleModuleChange}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
/>
</div>
</div>
)}
</div>
)}

View File

@ -2,6 +2,8 @@ import { useRouter } from "next/router";
// ui
import { Tooltip } from "components/ui";
// icons
import { ModuleStatusIcon } from "components/icons";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
// types
@ -49,6 +51,7 @@ export const ModuleGanttSidebarBlock = ({ data }: { data: IModule }) => {
className="relative w-full flex items-center gap-2 h-full"
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/modules/${data.id}`)}
>
<ModuleStatusIcon status={data?.status ?? "backlog"} height="16px" width="16px" />
<h6 className="text-sm font-medium flex-grow truncate">{data.name}</h6>
</div>
);

View File

@ -6,6 +6,7 @@ import { Controller, FieldError, Control } from "react-hook-form";
import { CustomSelect } from "components/ui";
// icons
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { ModuleStatusIcon } from "components/icons";
// types
import type { IModule } from "types";
// constants
@ -31,12 +32,7 @@ export const ModuleStatusSelect: React.FC<Props> = ({ control, error }) => (
}`}
>
{value ? (
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: MODULE_STATUS.find((s) => s.value === value)?.color,
}}
/>
<ModuleStatusIcon status={value} />
) : (
<Squares2X2Icon
className={`h-3 w-3 ${error ? "text-red-500" : "text-custom-text-200"}`}
@ -53,12 +49,7 @@ export const ModuleStatusSelect: React.FC<Props> = ({ control, error }) => (
{MODULE_STATUS.map((status) => (
<CustomSelect.Option key={status.value} value={status.value}>
<div className="flex items-center gap-2">
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: status.color,
}}
/>
<ModuleStatusIcon status={status.value} />
{status.label}
</div>
</CustomSelect.Option>

View File

@ -48,7 +48,7 @@ const defaultValues: Partial<IModule> = {
members_list: [],
start_date: null,
target_date: null,
status: null,
status: "backlog",
};
type Props = {

View File

@ -3,8 +3,6 @@ import Link from "next/link";
import useSWR from "swr";
// next-themes
import { useTheme } from "next-themes";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// services
@ -25,8 +23,6 @@ export const ProfileSidebar = () => {
const router = useRouter();
const { workspaceSlug, userId } = router.query;
const { theme } = useTheme();
const { user } = useUser();
const { data: userProjectsData } = useSWR(
@ -56,15 +52,7 @@ export const ProfileSidebar = () => {
];
return (
<div
className="flex-shrink-0 md:h-full w-full md:w-80 overflow-y-auto"
style={{
boxShadow:
theme === "light"
? "0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12)"
: "0px 0px 4px 0px rgba(0, 0, 0, 0.20), 0px 2px 6px 0px rgba(0, 0, 0, 0.50)",
}}
>
<div className="flex-shrink-0 md:h-full w-full md:w-80 overflow-y-auto shadow-custom-shadow-sm">
{userProjectsData ? (
<>
<div className="relative h-32">
@ -127,12 +115,11 @@ export const ProfileSidebar = () => {
project.assigned_issues +
project.pending_issues +
project.completed_issues;
const totalAssignedIssues = totalIssues - project.created_issues;
const completedIssuePercentage =
totalAssignedIssues === 0
project.assigned_issues === 0
? 0
: Math.round((project.completed_issues / totalAssignedIssues) * 100);
: Math.round((project.completed_issues / project.assigned_issues) * 100);
return (
<Disclosure

View File

@ -1,28 +1,38 @@
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
// next imports
import { useRouter } from "next/router";
// react-hook-form
import { useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui components
import { ToggleSwitch, PrimaryButton, SecondaryButton } from "components/ui";
import { ToggleSwitch, PrimaryButton, SecondaryButton, Icon, DangerButton } from "components/ui";
import { CustomPopover } from "./popover";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
import { IProjectPublishSettingsViews } from "store/project-publish";
import { IProjectPublishSettings, TProjectPublishViews } from "store/project-publish";
// hooks
import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
import useUser from "hooks/use-user";
type Props = {
// user: ICurrentUserResponse | undefined;
};
const defaultValues: Partial<any> = {
type FormData = {
id: string | null;
comments: boolean;
reactions: boolean;
votes: boolean;
inbox: string | null;
views: TProjectPublishViews[];
};
const defaultValues: FormData = {
id: null,
comments: false,
reactions: false,
@ -31,70 +41,73 @@ const defaultValues: Partial<any> = {
views: ["list", "kanban"],
};
const viewOptions = [
{ key: "list", value: "List" },
{ key: "kanban", value: "Kanban" },
// { key: "calendar", value: "Calendar" },
// { key: "gantt", value: "Gantt" },
// { key: "spreadsheet", value: "Spreadsheet" },
const viewOptions: {
key: TProjectPublishViews;
label: string;
}[] = [
{ key: "list", label: "List" },
{ key: "kanban", label: "Kanban" },
// { key: "calendar", label: "Calendar" },
// { key: "gantt", label: "Gantt" },
// { key: "spreadsheet", label: "Spreadsheet" },
];
export const PublishProjectModal: React.FC<Props> = observer(() => {
const store: RootStore = useMobxStore();
const { projectPublish } = store;
const [isUnpublishing, setIsUnpublishing] = useState(false);
const [isUpdateRequired, setIsUpdateRequired] = useState(false);
const { projectDetails, mutateProjectDetails } = useProjectDetails();
const { setToastAlert } = useToast();
const handleToastAlert = (title: string, type: string, message: string) => {
setToastAlert({
title: title || "Title",
type: "error" || "warning",
message: message || "Message",
});
};
const { NEXT_PUBLIC_DEPLOY_URL } = process.env;
const plane_deploy_url = NEXT_PUBLIC_DEPLOY_URL
? NEXT_PUBLIC_DEPLOY_URL
: "http://localhost:3001";
const plane_deploy_url = process.env.NEXT_PUBLIC_DEPLOY_URL ?? "http://localhost:4000";
const router = useRouter();
const { workspaceSlug } = router.query;
const store: RootStore = useMobxStore();
const { projectPublish } = store;
const { user } = useUser();
const { mutateProjectDetails } = useProjectDetails();
const { setToastAlert } = useToast();
const {
formState: { errors, isSubmitting },
control,
formState: { isSubmitting },
getValues,
handleSubmit,
reset,
watch,
setValue,
} = useForm<any>({
} = useForm<FormData>({
defaultValues,
reValidateMode: "onChange",
});
const handleClose = () => {
projectPublish.handleProjectModal(null);
setIsUpdateRequired(false);
reset({ ...defaultValues });
};
// prefill form with the saved settings if the project is already published
useEffect(() => {
if (
projectPublish.projectPublishSettings &&
projectPublish.projectPublishSettings != "not-initialized"
projectPublish.projectPublishSettings !== "not-initialized"
) {
let userBoards: string[] = [];
let userBoards: TProjectPublishViews[] = [];
if (projectPublish.projectPublishSettings?.views) {
const _views: IProjectPublishSettingsViews | null =
projectPublish.projectPublishSettings?.views || null;
if (_views != null) {
if (_views.list) userBoards.push("list");
if (_views.kanban) userBoards.push("kanban");
if (_views.calendar) userBoards.push("calendar");
if (_views.gantt) userBoards.push("gantt");
if (_views.spreadsheet) userBoards.push("spreadsheet");
userBoards = userBoards && userBoards.length > 0 ? userBoards : ["list"];
}
const savedViews = projectPublish.projectPublishSettings?.views;
if (!savedViews) return;
if (savedViews.list) userBoards.push("list");
if (savedViews.kanban) userBoards.push("kanban");
if (savedViews.calendar) userBoards.push("calendar");
if (savedViews.gantt) userBoards.push("gantt");
if (savedViews.spreadsheet) userBoards.push("spreadsheet");
userBoards = userBoards && userBoards.length > 0 ? userBoards : ["list"];
}
const updatedData = {
@ -105,126 +118,105 @@ export const PublishProjectModal: React.FC<Props> = observer(() => {
inbox: projectPublish.projectPublishSettings?.inbox || null,
views: userBoards,
};
reset({ ...updatedData });
}
}, [reset, projectPublish.projectPublishSettings]);
// fetch publish settings
useEffect(() => {
if (!workspaceSlug) return;
if (
projectPublish.projectPublishModal &&
workspaceSlug &&
projectPublish.project_id != null &&
projectPublish.project_id !== null &&
projectPublish?.projectPublishSettings === "not-initialized"
) {
projectPublish.getProjectSettingsAsync(
workspaceSlug as string,
projectPublish.project_id as string,
workspaceSlug.toString(),
projectPublish.project_id,
null
);
}
}, [workspaceSlug, projectPublish, projectPublish.projectPublishModal]);
const onSettingsPublish = async (formData: any) => {
if (formData.views && formData.views.length > 0) {
const payload = {
comments: formData.comments || false,
reactions: formData.reactions || false,
votes: formData.votes || false,
inbox: formData.inbox || null,
views: {
list: formData.views.includes("list") || false,
kanban: formData.views.includes("kanban") || false,
calendar: formData.views.includes("calendar") || false,
gantt: formData.views.includes("gantt") || false,
spreadsheet: formData.views.includes("spreadsheet") || false,
},
};
const handlePublishProject = async (payload: IProjectPublishSettings) => {
if (!workspaceSlug || !user) return;
const _workspaceSlug = workspaceSlug;
const _projectId = projectPublish.project_id;
return projectPublish
.createProjectSettingsAsync(_workspaceSlug as string, _projectId as string, payload, null)
.then((response) => {
mutateProjectDetails();
handleClose();
console.log("_projectId", _projectId);
if (_projectId)
window.open(`${plane_deploy_url}/${_workspaceSlug}/${_projectId}`, "_blank");
return response;
})
.catch((error) => {
console.error("error", error);
return error;
});
} else {
handleToastAlert("Missing fields", "warning", "Please select at least one view to publish");
}
};
const onSettingsUpdate = async (key: string, value: any) => {
const payload = {
comments: key === "comments" ? value : watch("comments"),
reactions: key === "reactions" ? value : watch("reactions"),
votes: key === "votes" ? value : watch("votes"),
inbox: key === "inbox" ? value : watch("inbox"),
views:
key === "views"
? {
list: value.includes("list") ? true : false,
kanban: value.includes("kanban") ? true : false,
calendar: value.includes("calendar") ? true : false,
gantt: value.includes("gantt") ? true : false,
spreadsheet: value.includes("spreadsheet") ? true : false,
}
: {
list: watch("views").includes("list") ? true : false,
kanban: watch("views").includes("kanban") ? true : false,
calendar: watch("views").includes("calendar") ? true : false,
gantt: watch("views").includes("gantt") ? true : false,
spreadsheet: watch("views").includes("spreadsheet") ? true : false,
},
};
const projectId = projectPublish.project_id;
return projectPublish
.updateProjectSettingsAsync(
workspaceSlug as string,
projectPublish.project_id as string,
watch("id"),
.createProjectSettingsAsync(
workspaceSlug.toString(),
projectId?.toString() ?? "",
payload,
null
user
)
.then((response) => {
mutateProjectDetails();
handleClose();
if (projectId) window.open(`${plane_deploy_url}/${workspaceSlug}/${projectId}`, "_blank");
return response;
})
.catch((error) => {
console.error("error", error);
return error;
});
};
const handleUpdatePublishSettings = async (payload: IProjectPublishSettings) => {
if (!workspaceSlug || !user) return;
await projectPublish
.updateProjectSettingsAsync(
workspaceSlug.toString(),
projectPublish.project_id?.toString() ?? "",
payload.id ?? "",
payload,
user
)
.then((res) => {
mutateProjectDetails();
setToastAlert({
type: "success",
title: "Success!",
message: "Publish settings updated successfully!",
});
handleClose();
return res;
})
.catch((error) => {
console.log("error", error);
return error;
});
};
const onSettingsUnPublish = async (formData: any) =>
const handleUnpublishProject = async (publishId: string) => {
if (!workspaceSlug || !publishId) return;
setIsUnpublishing(true);
projectPublish
.deleteProjectSettingsAsync(
workspaceSlug as string,
workspaceSlug.toString(),
projectPublish.project_id as string,
formData?.id,
publishId,
null
)
.then((response) => {
.then((res) => {
mutateProjectDetails();
reset({ ...defaultValues });
handleClose();
return response;
return res;
})
.catch((error) => {
console.error("error", error);
return error;
});
.catch((err) => err)
.finally(() => setIsUnpublishing(false));
};
const CopyLinkToClipboard = ({ copy_link }: { copy_link: string }) => {
const [status, setStatus] = React.useState(false);
const [status, setStatus] = useState(false);
const copyText = () => {
navigator.clipboard.writeText(copy_link);
@ -244,6 +236,68 @@ export const PublishProjectModal: React.FC<Props> = observer(() => {
);
};
const handleFormSubmit = async (formData: FormData) => {
if (!formData.views || formData.views.length === 0) {
setToastAlert({
type: "error",
title: "Error!",
message: "Please select at least one view layout to publish the project.",
});
return;
}
const payload = {
comments: formData.comments,
reactions: formData.reactions,
votes: formData.votes,
inbox: formData.inbox,
views: {
list: formData.views.includes("list"),
kanban: formData.views.includes("kanban"),
calendar: formData.views.includes("calendar"),
gantt: formData.views.includes("gantt"),
spreadsheet: formData.views.includes("spreadsheet"),
},
};
if (watch("id")) await handleUpdatePublishSettings({ id: watch("id") ?? "", ...payload });
else await handlePublishProject(payload);
};
// check if an update is required or not
const checkIfUpdateIsRequired = () => {
if (
!projectPublish.projectPublishSettings ||
projectPublish.projectPublishSettings === "not-initialized"
)
return;
const currentSettings = projectPublish.projectPublishSettings as IProjectPublishSettings;
const newSettings = getValues();
if (
currentSettings.comments !== newSettings.comments ||
currentSettings.reactions !== newSettings.reactions ||
currentSettings.votes !== newSettings.votes
) {
setIsUpdateRequired(true);
return;
}
let viewCheckFlag = 0;
viewOptions.forEach((option) => {
if (currentSettings.views[option.key] !== newSettings.views.includes(option.key))
viewCheckFlag++;
});
if (viewCheckFlag !== 0) {
setIsUpdateRequired(true);
return;
}
setIsUpdateRequired(false);
};
return (
<Transition.Root show={projectPublish.projectPublishModal} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
@ -270,200 +324,190 @@ export const PublishProjectModal: React.FC<Props> = observer(() => {
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="transform rounded-lg bg-custom-background-100 border border-custom-border-100 text-left shadow-xl transition-all w-full sm:w-3/5 lg:w-1/2 xl:w-2/5 space-y-4">
{/* heading */}
<div className="p-3 px-4 pb-0 flex gap-2 justify-between items-center">
<div className="font-medium text-xl">Publish</div>
{projectPublish.loader && (
<div className="text-xs text-custom-text-400">Changes saved</div>
)}
<div
className="hover:bg-custom-background-90 w-[30px] h-[30px] rounded flex justify-center items-center cursor-pointer transition-all"
onClick={handleClose}
>
<span className="material-symbols-rounded text-[16px]">close</span>
</div>
</div>
{/* content */}
<div className="space-y-3">
{watch("id") && (
<div className="flex items-center gap-1 px-4 text-custom-primary-100">
<div className="w-[20px] h-[20px] overflow-hidden flex items-center">
<span className="material-symbols-rounded text-[18px]">
radio_button_checked
</span>
</div>
<div className="text-sm">This project is live on web</div>
</div>
)}
<div className="mx-4 border border-custom-border-100 bg-custom-background-90 rounded p-3 py-2 relative flex gap-2 items-center">
<div className="relative line-clamp-1 overflow-hidden w-full text-sm">
{`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
</div>
<div className="flex-shrink-0 relative flex items-center gap-1">
<a
href={`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
target="_blank"
rel="noreferrer"
<Dialog.Panel className="transform rounded-lg bg-custom-background-100 border border-custom-border-100 text-left shadow-xl transition-all w-full sm:w-3/5 lg:w-1/2 xl:w-2/5 ">
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
{/* heading */}
<div className="px-6 pt-4 flex items-center justify-between gap-2">
<h5 className="font-semibold text-xl inline-block">Publish</h5>
{watch("id") && (
<DangerButton
onClick={() => handleUnpublishProject(watch("id") ?? "")}
className="!px-2 !py-1.5"
loading={isUnpublishing}
>
<div className="border border-custom-border-100 bg-custom-background-100 w-[30px] h-[30px] rounded flex justify-center items-center hover:bg-custom-background-90 cursor-pointer">
<span className="material-symbols-rounded text-[16px]">open_in_new</span>
</div>
</a>
<CopyLinkToClipboard
copy_link={`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
/>
</div>
</div>
<div className="space-y-3 px-4">
<div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Views</div>
<div>
<CustomPopover
label={
watch("views") && watch("views").length > 0
? viewOptions
.filter(
(_view) => watch("views").includes(_view.key) && _view.value
)
.map((_view) => _view.value)
.join(", ")
: ``
}
placeholder="Select views"
>
<>
{viewOptions &&
viewOptions.length > 0 &&
viewOptions.map((_view) => (
<div
key={_view.value}
className={`relative flex items-center gap-2 justify-between p-1 m-1 px-2 cursor-pointer rounded-sm text-custom-text-200 ${
watch("views").includes(_view.key)
? `bg-custom-background-80 text-custom-text-100`
: `hover:bg-custom-background-80 hover:text-custom-text-100`
}`}
onClick={() => {
const _views =
watch("views") && watch("views").length > 0
? watch("views").includes(_view?.key)
? watch("views").filter((_o: string) => _o !== _view?.key)
: [...watch("views"), _view?.key]
: [_view?.key];
setValue("views", _views);
if (watch("id") != null) onSettingsUpdate("views", _views);
}}
>
<div className="text-sm">{_view.value}</div>
<div
className={`w-[18px] h-[18px] relative flex justify-center items-center`}
>
{watch("views") &&
watch("views").length > 0 &&
watch("views").includes(_view.key) && (
<span className="material-symbols-rounded text-[18px]">
done
</span>
)}
</div>
</div>
))}
</>
</CustomPopover>
</div>
</div>
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow comments</div>
<div>
<ToggleSwitch
value={watch("comments") ?? false}
onChange={() => {
const _comments = !watch("comments");
setValue("comments", _comments);
if (watch("id") != null) onSettingsUpdate("comments", _comments);
}}
size="sm"
/>
</div>
</div> */}
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow reactions</div>
<div>
<ToggleSwitch
value={watch("reactions") ?? false}
onChange={() => {
const _reactions = !watch("reactions");
setValue("reactions", _reactions);
if (watch("id") != null) onSettingsUpdate("reactions", _reactions);
}}
size="sm"
/>
</div>
</div> */}
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow Voting</div>
<div>
<ToggleSwitch
value={watch("votes") ?? false}
onChange={() => {
const _votes = !watch("votes");
setValue("votes", _votes);
if (watch("id") != null) onSettingsUpdate("votes", _votes);
}}
size="sm"
/>
</div>
</div> */}
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-custom-text-100">Allow issue proposals</div>
<div>
<ToggleSwitch
value={watch("inbox") ?? false}
onChange={() => {
setValue("inbox", !watch("inbox"));
}}
size="sm"
/>
</div>
</div> */}
</div>
</div>
{/* modal handlers */}
<div className="border-t border-custom-border-300 p-3 px-4 relative flex justify-between items-center">
<div className="flex items-center gap-1 text-custom-text-300">
<div className="w-[20px] h-[20px] overflow-hidden flex items-center">
<span className="material-symbols-rounded text-[18px]">public</span>
</div>
<div className="text-sm">Anyone with the link can access</div>
</div>
<div className="relative flex items-center gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
{watch("id") != null ? (
<PrimaryButton
outline
onClick={handleSubmit(onSettingsUnPublish)}
disabled={isSubmitting}
>
{isSubmitting ? "Unpublishing..." : "Unpublish"}
</PrimaryButton>
) : (
<PrimaryButton
onClick={handleSubmit(onSettingsPublish)}
disabled={isSubmitting}
>
{isSubmitting ? "Publishing..." : "Publish"}
</PrimaryButton>
{isUnpublishing ? "Unpublishing..." : "Unpublish"}
</DangerButton>
)}
</div>
</div>
{/* content */}
<div className="space-y-3 px-6">
<div className="border border-custom-border-100 bg-custom-background-80 rounded-md px-3 py-2 relative flex gap-2 items-center">
<div className="truncate flex-grow text-sm">
{`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
</div>
<div className="flex-shrink-0 relative flex items-center gap-1">
<CopyLinkToClipboard
copy_link={`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`}
/>
</div>
</div>
{watch("id") && (
<div className="flex items-center gap-1 text-custom-primary-100">
<div className="w-5 h-5 overflow-hidden flex items-center">
<Icon iconName="radio_button_checked" className="!text-lg" />
</div>
<div className="text-sm">This project is live on web</div>
</div>
)}
<div className="space-y-4">
<div className="relative flex justify-between items-center gap-2">
<div className="text-sm">Views</div>
<Controller
control={control}
name="views"
render={({ field: { onChange, value } }) => (
<CustomPopover
label={
value.length > 0
? viewOptions
.filter((v) => value.includes(v.key))
.map((v) => v.label)
.join(", ")
: ``
}
placeholder="Select views"
>
<>
{viewOptions.map((option) => (
<div
key={option.key}
className={`relative flex items-center gap-2 justify-between p-1 m-1 px-2 cursor-pointer rounded-sm text-custom-text-200 ${
value.includes(option.key)
? "bg-custom-background-80 text-custom-text-100"
: "hover:bg-custom-background-80 hover:text-custom-text-100"
}`}
onClick={() => {
const _views =
value.length > 0
? value.includes(option.key)
? value.filter((_o: string) => _o !== option.key)
: [...value, option.key]
: [option.key];
if (_views.length === 0) return;
onChange(_views);
checkIfUpdateIsRequired();
}}
>
<div className="text-sm">{option.label}</div>
<div
className={`w-[18px] h-[18px] relative flex justify-center items-center`}
>
{value.length > 0 && value.includes(option.key) && (
<Icon iconName="done" className="!text-lg" />
)}
</div>
</div>
))}
</>
</CustomPopover>
)}
/>
</div>
<div className="relative flex justify-between items-center gap-2">
<div className="text-sm">Allow comments</div>
<Controller
control={control}
name="comments"
render={({ field: { onChange, value } }) => (
<ToggleSwitch
value={value}
onChange={(val) => {
onChange(val);
checkIfUpdateIsRequired();
}}
size="sm"
/>
)}
/>
</div>
<div className="relative flex justify-between items-center gap-2">
<div className="text-sm">Allow reactions</div>
<Controller
control={control}
name="reactions"
render={({ field: { onChange, value } }) => (
<ToggleSwitch
value={value}
onChange={(val) => {
onChange(val);
checkIfUpdateIsRequired();
}}
size="sm"
/>
)}
/>
</div>
<div className="relative flex justify-between items-center gap-2">
<div className="text-sm">Allow voting</div>
<Controller
control={control}
name="votes"
render={({ field: { onChange, value } }) => (
<ToggleSwitch
value={value}
onChange={(val) => {
onChange(val);
checkIfUpdateIsRequired();
}}
size="sm"
/>
)}
/>
</div>
{/* <div className="relative flex justify-between items-center gap-2">
<div className="text-sm">Allow issue proposals</div>
<Controller
control={control}
name="inbox"
render={({ field: { onChange, value } }) => (
<ToggleSwitch value={value} onChange={onChange} size="sm" />
)}
/>
</div> */}
</div>
</div>
{/* modal handlers */}
<div className="border-t border-custom-border-200 px-6 py-5 relative flex justify-between items-center">
<div className="flex items-center gap-1 text-custom-text-400 text-sm">
<Icon iconName="public" className="!text-base" />
<div className="text-sm">Anyone with the link can access</div>
</div>
<div className="relative flex items-center gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
{watch("id") ? (
<>
{isUpdateRequired && (
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Updating..." : "Update settings"}
</PrimaryButton>
)}
</>
) : (
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Publishing..." : "Publish"}
</PrimaryButton>
)}
</div>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>

View File

@ -1,6 +1,9 @@
import React, { Fragment } from "react";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// icons
import { Icon } from "components/ui";
export const CustomPopover = ({
children,
@ -16,18 +19,14 @@ export const CustomPopover = ({
{({ open }) => (
<>
<Popover.Button
className={`${
open ? "" : ""
} relative flex items-center gap-1 border border-custom-border-300 shadow-sm p-1 px-2 ring-0 outline-none`}
className={`${open ? "" : ""} relative flex items-center gap-1 ring-0 outline-none`}
>
<div className="text-sm font-medium">
{label ? label : placeholder ? placeholder : "Select"}
</div>
<div className="w-[20px] h-[20px] relative flex justify-center items-center">
<div className="text-sm">{label ?? placeholder}</div>
<div className="w-5 h-5 grid place-items-center">
{!open ? (
<span className="material-symbols-rounded text-[20px]">expand_more</span>
<Icon iconName="expand_more" className="!text-base" />
) : (
<span className="material-symbols-rounded text-[20px]">expand_less</span>
<Icon iconName="expand_less" className="!text-base" />
)}
</div>
</Popover.Button>
@ -41,8 +40,8 @@ export const CustomPopover = ({
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-[9999]">
<div className="overflow-hidden rounded-sm border border-custom-border-300 mt-1 overflow-y-auto bg-custom-background-90 shadow-lg focus:outline-none">
<Popover.Panel className="absolute right-0 z-10 mt-1 min-w-[150px]">
<div className="overflow-hidden rounded border border-custom-border-300 mt-1 overflow-y-auto bg-custom-background-90 shadow-custom-shadow-2xs focus:outline-none">
{children}
</div>
</Popover.Panel>

View File

@ -26,7 +26,6 @@ import {
SettingsOutlined,
} from "@mui/icons-material";
// helpers
import { truncateText } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// types
import { IProject } from "types";
@ -265,11 +264,10 @@ export const SingleSidebarProject: React.FC<Props> = observer(
>
<div className="flex-shrink-0 relative flex items-center justify-start gap-2">
<div className="rounded transition-all w-4 h-4 flex justify-center items-center text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 duration-300 cursor-pointer">
<span className="material-symbols-rounded text-[16px]">ios_share</span>
<Icon iconName="ios_share" className="!text-base" />
</div>
<div>Publish</div>
<div>{project.is_deployed ? "Publish settings" : "Publish"}</div>
</div>
{/* <PublishProjectModal /> */}
</CustomMenu.MenuItem>
)}

View File

@ -77,14 +77,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
{...bubbleMenuProps}
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
>
<NodeSelector
{!props.editor.isActive("table") && <NodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsLinkSelectorOpen(false);
}}
/>
/>}
<LinkSelector
editor={props.editor!!}
isOpen={isLinkSelectorOpen}

View File

@ -6,7 +6,7 @@ import isValidHttpUrl from "./utils/link-validator";
interface LinkSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
setIsOpen: Dispatch<SetStateAction<boolean>>
}
@ -52,7 +52,8 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 dow-xl animate-in fade-in slide-in-from-top-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); onLinkSubmit();
e.preventDefault();
onLinkSubmit();
}
}}
>

View File

@ -13,6 +13,7 @@ import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { lowlight } from "lowlight/lib/core";
import SlashCommand from "../slash-command";
import { InputRule } from "@tiptap/core";
import Gapcursor from '@tiptap/extension-gapcursor'
import ts from "highlight.js/lib/languages/typescript";
@ -20,6 +21,10 @@ import "highlight.js/styles/github-dark.css";
import UniqueID from "@tiptap-pro/extension-unique-id";
import UpdatedImage from "./updated-image";
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
import { CustomTableCell } from "./table/table-cell";
import { Table } from "./table/table";
import { TableHeader } from "./table/table-header";
import { TableRow } from "@tiptap/extension-table-row";
lowlight.registerLanguage("ts", ts);
@ -55,7 +60,7 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
@ -86,6 +91,7 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub
class: "mb-6 border-t border-custom-border-300",
},
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
@ -104,6 +110,9 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return ""
}
return "Press '/' for commands...";
},
@ -134,4 +143,8 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
CustomTableCell,
TableRow
];

View File

@ -0,0 +1,31 @@
import { TableCell } from "@tiptap/extension-table-cell";
export const CustomTableCell = TableCell.extend({
addAttributes() {
return {
...this.parent?.(),
isHeader: {
default: false,
parseHTML: (element) => { isHeader: element.tagName === "TD" },
renderHTML: (attributes) => { tag: attributes.isHeader ? "th" : "td" }
},
};
},
renderHTML({ HTMLAttributes }) {
if (HTMLAttributes.isHeader) {
return [
"th",
{
...HTMLAttributes,
class: `relative ${HTMLAttributes.class}`,
},
[
"span",
{ class: "absolute top-0 right-0" },
],
0,
];
}
return ["td", HTMLAttributes, 0];
},
});

View File

@ -0,0 +1,7 @@
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
const TableHeader = BaseTableHeader.extend({
content: "paragraph"
});
export { TableHeader };

View File

@ -0,0 +1,9 @@
import { Table as BaseTable } from "@tiptap/extension-table";
const Table = BaseTable.configure({
resizable: true,
cellMinWidth: 100,
allowTableNodeSelection: true
});
export { Table };

View File

@ -5,6 +5,7 @@ import { TiptapExtensions } from "./extensions";
import { TiptapEditorProps } from "./props";
import { useImperativeHandle, useRef, forwardRef } from "react";
import { ImageResizer } from "./extensions/image-resize";
import { TableMenu } from "./table-menu";
export interface ITipTapRichTextEditor {
value: string;
@ -75,8 +76,8 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
const editorClassNames = `relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md
${noBorder ? "" : "border border-custom-border-200"} ${
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
} ${customClassName}`;
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
} ${customClassName}`;
if (!editor) return null;
editorRef.current = editor;
@ -92,6 +93,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
{editor && <EditorBubbleMenu editor={editor} />}
<div className={`${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
<TableMenu editor={editor} />
{editor?.isActive("image") && <ImageResizer editor={editor} />}
</div>
</div>

View File

@ -16,6 +16,7 @@ const TrackImageDeletionPlugin = () =>
oldState.doc.descendants((oldNode, oldPos) => {
if (oldNode.type.name !== 'image') return;
if (oldPos < 0 || oldPos > newState.doc.content.size) return;
if (!newState.doc.resolve(oldPos).parent) return;
const newNode = newState.doc.nodeAt(oldPos);
@ -28,7 +29,6 @@ const TrackImageDeletionPlugin = () =>
nodeExists = true;
}
});
if (!nodeExists) {
removedImages.push(oldNode as ProseMirrorNode);
}

View File

@ -60,8 +60,6 @@ function findPlaceholder(state: EditorState, id: {}) {
export async function startImageUpload(file: File, view: EditorView, pos: number, workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) {
if (!file.type.includes("image/")) {
return;
} else if (file.size / 1024 / 1024 > 20) {
return;
}
const id = {};

View File

@ -1,5 +1,6 @@
import { EditorProps } from "@tiptap/pm/view";
import { startImageUpload } from "./plugins/upload-image";
import { findTableAncestor } from "./table-menu";
export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorProps {
return {
@ -18,6 +19,15 @@ export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSu
},
},
handlePaste: (view, event) => {
if (typeof window !== "undefined") {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
}
}
if (
event.clipboardData &&
event.clipboardData.files &&
@ -32,6 +42,15 @@ export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSu
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (typeof window !== "undefined") {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
}
}
if (
!moved &&
event.dataTransfer &&

View File

@ -15,6 +15,7 @@ import {
MinusSquare,
CheckSquare,
ImageIcon,
Table,
} from "lucide-react";
import { startImageUpload } from "../plugins/upload-image";
import { cn } from "../utils";
@ -46,6 +47,9 @@ const Command = Extension.create({
return [
Suggestion({
editor: this.editor,
allow({ editor }) {
return !editor.isActive("table");
},
...this.options.suggestion,
}),
];
@ -117,6 +121,15 @@ const getSuggestionItems = (workspaceSlug: string, setIsSubmitting?: (isSubmitti
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: "Table",
description: "Create a Table",
searchTerms: ["table", "cell", "db", "data", "tabular"],
icon: <Table size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",

View File

@ -0,0 +1,127 @@
import { useState, useEffect } from "react";
import { Rows, Columns, ToggleRight } from "lucide-react";
import { cn } from "../utils";
import { Tooltip } from "components/ui";
interface TableMenuItem {
command: () => void;
icon: any;
key: string;
name: string;
}
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
while (node !== null && node.nodeName !== "TABLE") {
node = node.parentNode;
}
return node as HTMLTableElement;
};
export const TableMenu = ({ editor }: { editor: any }) => {
const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 });
const isOpen = editor?.isActive("table");
const items: TableMenuItem[] = [
{
command: () => editor.chain().focus().addColumnBefore().run(),
icon: Columns,
key: "insert-column-right",
name: "Insert 1 column right",
},
{
command: () => editor.chain().focus().addRowAfter().run(),
icon: Rows,
key: "insert-row-below",
name: "Insert 1 row below",
},
{
command: () => editor.chain().focus().deleteColumn().run(),
icon: Columns,
key: "delete-column",
name: "Delete column",
},
{
command: () => editor.chain().focus().deleteRow().run(),
icon: Rows,
key: "delete-row",
name: "Delete row",
},
{
command: () => editor.chain().focus().toggleHeaderRow().run(),
icon: ToggleRight,
key: "toggle-header-row",
name: "Toggle header row",
},
];
useEffect(() => {
if (!window) return;
const handleWindowClick = () => {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
const tableNode = findTableAncestor(range.startContainer);
let parent = tableNode?.parentElement;
if (tableNode) {
const tableRect = tableNode.getBoundingClientRect();
const tableCenter = tableRect.left + tableRect.width / 2;
const menuWidth = 45;
const menuLeft = tableCenter - menuWidth / 2;
const tableBottom = tableRect.bottom;
setTableLocation({ bottom: tableBottom, left: menuLeft });
while (parent) {
if (!parent.classList.contains("disable-scroll"))
parent.classList.add("disable-scroll");
parent = parent.parentElement;
}
} else {
const scrollDisabledContainers = document.querySelectorAll(".disable-scroll");
scrollDisabledContainers.forEach((container) => {
container.classList.remove("disable-scroll");
});
}
}
};
window.addEventListener("click", handleWindowClick);
return () => {
window.removeEventListener("click", handleWindowClick);
};
}, [tableLocation, editor]);
return (
<section
className={`fixed left-1/2 transform -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${
isOpen ? "block" : "hidden"
}`}
style={{
bottom: `calc(100vh - ${tableLocation.bottom + 45}px)`,
left: `${tableLocation.left}px`,
}}
>
{items.map((item, index) => (
<Tooltip key={index} tooltipContent={item.name}>
<button
onClick={item.command}
className="p-1.5 text-custom-text-200 hover:bg-text-custom-text-100 hover:bg-custom-background-80 active:bg-custom-background-80 rounded"
title={item.name}
>
<item.icon
className={cn("h-4 w-4 text-lg", {
"text-red-600": item.key.includes("delete"),
})}
/>
</button>
</Tooltip>
))}
</section>
);
};

View File

@ -1,8 +1,15 @@
export const MODULE_STATUS = [
{ label: "Backlog", value: "backlog", color: "#5e6ad2" },
{ label: "Planned", value: "planned", color: "#26b5ce" },
{ label: "In Progress", value: "in-progress", color: "#f2c94c" },
{ label: "Paused", value: "paused", color: "#ff6900" },
{ label: "Completed", value: "completed", color: "#4cb782" },
{ label: "Cancelled", value: "cancelled", color: "#cc1d10" },
// types
import { TModuleStatus } from "types";
export const MODULE_STATUS: {
label: string;
value: TModuleStatus;
color: string;
}[] = [
{ label: "Backlog", value: "backlog", color: "#a3a3a2" },
{ label: "Planned", value: "planned", color: "#3f76ff" },
{ label: "In Progress", value: "in-progress", color: "#f39e1f" },
{ label: "Paused", value: "paused", color: "#525252" },
{ label: "Completed", value: "completed", color: "#16a34a" },
{ label: "Cancelled", value: "cancelled", color: "#ef4444" },
];

File diff suppressed because it is too large Load Diff

View File

@ -197,23 +197,26 @@ export const ProfileIssuesContextProvider: React.FC<{ children: React.ReactNode
}) => {
const [state, dispatch] = useReducer(reducer, initialState);
const setIssueView = useCallback((property: TIssueViewOptions) => {
dispatch({
type: "SET_ISSUE_VIEW",
payload: {
issueView: property,
},
});
if (property === "kanban") {
const setIssueView = useCallback(
(property: TIssueViewOptions) => {
dispatch({
type: "SET_GROUP_BY_PROPERTY",
type: "SET_ISSUE_VIEW",
payload: {
groupByProperty: "state_detail.group",
issueView: property,
},
});
}
}, []);
if (property === "kanban" && state.groupByProperty === null) {
dispatch({
type: "SET_GROUP_BY_PROPERTY",
payload: {
groupByProperty: "state_detail.group",
},
});
}
},
[state]
);
const setGroupByProperty = useCallback((property: TIssueGroupByOptions) => {
dispatch({

View File

@ -23,6 +23,7 @@ const useGanttChartCycleIssues = (
priority: filters?.priority ? filters?.priority.join(",") : undefined,
labels: filters?.labels ? filters?.labels.join(",") : undefined,
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
start_date: filters?.start_date ? filters?.start_date.join(",") : undefined,
target_date: filters?.target_date ? filters?.target_date.join(",") : undefined,
start_target_date: true, // to fetch only issues with a start and target date
};

View File

@ -19,6 +19,7 @@ const useGanttChartIssues = (workspaceSlug: string | undefined, projectId: strin
priority: filters?.priority ? filters?.priority.join(",") : undefined,
labels: filters?.labels ? filters?.labels.join(",") : undefined,
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
start_date: filters?.start_date ? filters?.start_date.join(",") : undefined,
target_date: filters?.target_date ? filters?.target_date.join(",") : undefined,
start_target_date: true, // to fetch only issues with a start and target date
};

View File

@ -23,6 +23,7 @@ const useGanttChartModuleIssues = (
priority: filters?.priority ? filters?.priority.join(",") : undefined,
labels: filters?.labels ? filters?.labels.join(",") : undefined,
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
start_date: filters?.start_date ? filters?.start_date.join(",") : undefined,
target_date: filters?.target_date ? filters?.target_date.join(",") : undefined,
start_target_date: true, // to fetch only issues with a start and target date
};

View File

@ -42,6 +42,7 @@ const useCalendarIssuesView = () => {
type: filters?.type ? filters?.type : undefined,
labels: filters?.labels ? filters?.labels.join(",") : undefined,
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
start_date: filters?.start_date ? filters?.start_date.join(",") : undefined,
target_date: calendarDateRange,
};

View File

@ -71,8 +71,23 @@ const useProfileIssues = (workspaceSlug: string | undefined, userId: string | un
allIssues: userProfileIssues,
};
if (groupByProperty === "state_detail.group") {
return userProfileIssues
? Object.assign(
{
backlog: [],
unstarted: [],
started: [],
completed: [],
cancelled: [],
},
userProfileIssues
)
: undefined;
}
return userProfileIssues;
}, [userProfileIssues]);
}, [groupByProperty, userProfileIssues]);
useEffect(() => {
if (!userId || !filters) return;

View File

@ -43,10 +43,12 @@ const useSpreadsheetIssuesView = () => {
type: filters?.type ? filters?.type : undefined,
labels: filters?.labels ? filters?.labels.join(",") : undefined,
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
start_date: filters?.start_date ? filters?.start_date.join(",") : undefined,
target_date: filters?.target_date ? filters?.target_date.join(",") : undefined,
sub_issue: "false",
};
const { data: projectSpreadsheetIssues } = useSWR(
const { data: projectSpreadsheetIssues, mutate: mutateProjectSpreadsheetIssues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params)
: null,
@ -56,7 +58,7 @@ const useSpreadsheetIssuesView = () => {
: null
);
const { data: cycleSpreadsheetIssues } = useSWR(
const { data: cycleSpreadsheetIssues, mutate: mutateCycleSpreadsheetIssues } = useSWR(
workspaceSlug && projectId && cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
: null,
@ -71,7 +73,7 @@ const useSpreadsheetIssuesView = () => {
: null
);
const { data: moduleSpreadsheetIssues } = useSWR(
const { data: moduleSpreadsheetIssues, mutate: mutateModuleSpreadsheetIssues } = useSWR(
workspaceSlug && projectId && moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: null,
@ -86,7 +88,7 @@ const useSpreadsheetIssuesView = () => {
: null
);
const { data: viewSpreadsheetIssues } = useSWR(
const { data: viewSpreadsheetIssues, mutate: mutateViewSpreadsheetIssues } = useSWR(
workspaceSlug && projectId && viewId && params ? VIEW_ISSUES(viewId.toString(), params) : null,
workspaceSlug && projectId && viewId && params
? () =>
@ -104,6 +106,13 @@ const useSpreadsheetIssuesView = () => {
return {
issueView,
mutateIssues: cycleId
? mutateCycleSpreadsheetIssues
: moduleId
? mutateModuleSpreadsheetIssues
: viewId
? mutateViewSpreadsheetIssues
: mutateProjectSpreadsheetIssues,
spreadsheetIssues: spreadsheetIssues ?? [],
orderBy,
setOrderBy,

View File

@ -30,11 +30,16 @@
"@tiptap-pro/extension-unique-id": "^2.1.0",
"@tiptap/extension-code-block-lowlight": "^2.0.4",
"@tiptap/extension-color": "^2.0.4",
"@tiptap/extension-gapcursor": "^2.1.7",
"@tiptap/extension-highlight": "^2.0.4",
"@tiptap/extension-horizontal-rule": "^2.0.4",
"@tiptap/extension-image": "^2.0.4",
"@tiptap/extension-link": "^2.0.4",
"@tiptap/extension-placeholder": "^2.0.4",
"@tiptap/extension-table": "^2.1.6",
"@tiptap/extension-table-cell": "^2.1.6",
"@tiptap/extension-table-header": "^2.1.6",
"@tiptap/extension-table-row": "^2.1.6",
"@tiptap/extension-task-item": "^2.0.4",
"@tiptap/extension-task-list": "^2.0.4",
"@tiptap/extension-text-style": "^2.0.4",

View File

@ -14,7 +14,14 @@ import SettingsNavbar from "layouts/settings-navbar";
// components
import { ImagePickerPopover, ImageUploadModal } from "components/core";
// ui
import { CustomSelect, DangerButton, Input, SecondaryButton, Spinner } from "components/ui";
import {
CustomSearchSelect,
CustomSelect,
DangerButton,
Input,
SecondaryButton,
Spinner,
} from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import { UserIcon } from "@heroicons/react/24/outline";
@ -23,6 +30,7 @@ import type { NextPage } from "next";
import type { IUser } from "types";
// constants
import { USER_ROLES } from "constants/workspace";
import { TIME_ZONES } from "constants/timezones";
const defaultValues: Partial<IUser> = {
avatar: "",
@ -31,6 +39,7 @@ const defaultValues: Partial<IUser> = {
last_name: "",
email: "",
role: "Product / Project Manager",
user_timezone: "Asia/Kolkata",
};
const Profile: NextPage = () => {
@ -72,6 +81,7 @@ const Profile: NextPage = () => {
cover_image: formData.cover_image,
role: formData.role,
display_name: formData.display_name,
user_timezone: formData.user_timezone,
};
await userService
@ -128,6 +138,12 @@ const Profile: NextPage = () => {
});
};
const timeZoneOptions = TIME_ZONES.map((timeZone) => ({
value: timeZone.value,
query: timeZone.label + " " + timeZone.value,
content: timeZone.label,
}));
return (
<WorkspaceAuthorizationLayout
breadcrumbs={
@ -348,6 +364,35 @@ const Profile: NextPage = () => {
{errors.role && <span className="text-xs text-red-500">Please select a role</span>}
</div>
</div>
<div className="grid grid-cols-12 gap-4 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-custom-text-100">Timezone</h4>
<p className="text-sm text-custom-text-200">Select a timezone</p>
</div>
<div className="col-span-12 sm:col-span-6">
<Controller
name="user_timezone"
control={control}
rules={{ required: "This field is required" }}
render={({ field: { value, onChange } }) => (
<CustomSearchSelect
value={value}
label={
value
? TIME_ZONES.find((t) => t.value === value)?.label ?? value
: "Select a timezone"
}
options={timeZoneOptions}
onChange={onChange}
verticalPosition="top"
optionsClassName="w-full"
input
/>
)}
/>
{errors.role && <span className="text-xs text-red-500">Please select a role</span>}
</div>
</div>
<div className="sm:text-right">
<SecondaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Updating..." : "Update profile"}

View File

@ -182,7 +182,7 @@ class ProjectIssuesServices extends APIService {
workspaceSlug: string,
projectId: string,
issueId: string,
data: any,
data: Partial<IIssueComment>,
user: ICurrentUserResponse | undefined
): Promise<any> {
return this.post(
@ -468,20 +468,18 @@ class ProjectIssuesServices extends APIService {
metadata: any;
title: string;
url: string;
},
}
): Promise<any> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-links/${linkId}/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async deleteIssueLink(
workspaceSlug: string,
projectId: string,

172
apps/app/store/issues.ts Normal file
View File

@ -0,0 +1,172 @@
// mobx
import { action, observable, runInAction, makeAutoObservable } from "mobx";
// services
import issueService from "services/issues.service";
// types
import type { ICurrentUserResponse, IIssue } from "types";
class IssuesStore {
issues: { [key: string]: IIssue } = {};
isIssuesLoading: boolean = false;
rootStore: any | null = null;
constructor(_rootStore: any | null = null) {
makeAutoObservable(this, {
issues: observable.ref,
loadIssues: action,
getIssueById: action,
isIssuesLoading: observable,
createIssue: action,
updateIssue: action,
deleteIssue: action,
});
this.rootStore = _rootStore;
}
/**
* @description Fetch all issues of a project and hydrate issues field
*/
loadIssues = async (workspaceSlug: string, projectId: string) => {
this.isIssuesLoading = true;
try {
const issuesResponse: IIssue[] = (await issueService.getIssuesWithParams(
workspaceSlug,
projectId
)) as IIssue[];
const issues: { [kye: string]: IIssue } = {};
issuesResponse.forEach((issue) => {
issues[issue.id] = issue;
});
runInAction(() => {
this.issues = issues;
this.isIssuesLoading = false;
});
} catch (error) {
this.isIssuesLoading = false;
console.error("Fetching issues error", error);
}
};
getIssueById = async (
workspaceSlug: string,
projectId: string,
issueId: string
): Promise<IIssue> => {
if (this.issues[issueId]) return this.issues[issueId];
try {
const issueResponse: IIssue = await issueService.retrieve(workspaceSlug, projectId, issueId);
const issues = {
...this.issues,
[issueId]: { ...issueResponse },
};
runInAction(() => {
this.issues = issues;
});
return issueResponse;
} catch (error) {
throw error;
}
};
createIssue = async (
workspaceSlug: string,
projectId: string,
issueForm: IIssue,
user: ICurrentUserResponse
): Promise<IIssue> => {
try {
const issueResponse = await issueService.createIssues(
workspaceSlug,
projectId,
issueForm,
user
);
const issues = {
...this.issues,
[issueResponse.id]: { ...issueResponse },
};
runInAction(() => {
this.issues = issues;
});
return issueResponse;
} catch (error) {
console.error("Creating issue error", error);
throw error;
}
};
updateIssue = async (
workspaceSlug: string,
projectId: string,
issueId: string,
issueForm: Partial<IIssue>,
user: ICurrentUserResponse
) => {
// keep a copy of the issue in the store
const originalIssue = { ...this.issues[issueId] };
// immediately update the issue in the store
const updatedIssue = { ...originalIssue, ...issueForm };
try {
runInAction(() => {
this.issues[issueId] = updatedIssue;
});
// make a patch request to update the issue
const issueResponse: IIssue = await issueService.patchIssue(
workspaceSlug,
projectId,
issueId,
issueForm,
user
);
const updatedIssues = { ...this.issues };
updatedIssues[issueId] = { ...issueResponse };
runInAction(() => {
this.issues = updatedIssues;
});
} catch (error) {
// if there is an error, revert the changes
runInAction(() => {
this.issues[issueId] = originalIssue;
});
return error;
}
};
deleteIssue = async (
workspaceSlug: string,
projectId: string,
issueId: string,
user: ICurrentUserResponse
) => {
const issues = { ...this.issues };
delete issues[issueId];
try {
runInAction(() => {
this.issues = issues;
});
issueService.deleteIssue(workspaceSlug, projectId, issueId, user);
} catch (error) {
console.error("Deleting issue error", error);
}
};
}
export default IssuesStore;

View File

@ -4,21 +4,11 @@ import { RootStore } from "./root";
// services
import ProjectServices from "services/project-publish.service";
export type IProjectPublishSettingsViewKeys =
| "list"
| "gantt"
| "kanban"
| "calendar"
| "spreadsheet"
| string;
export type TProjectPublishViews = "list" | "gantt" | "kanban" | "calendar" | "spreadsheet";
export interface IProjectPublishSettingsViews {
list: boolean;
gantt: boolean;
kanban: boolean;
calendar: boolean;
spreadsheet: boolean;
}
export type TProjectPublishViewsSettings = {
[key in TProjectPublishViews]: boolean;
};
export interface IProjectPublishSettings {
id?: string;
@ -26,8 +16,8 @@ export interface IProjectPublishSettings {
comments: boolean;
reactions: boolean;
votes: boolean;
views: IProjectPublishSettingsViews;
inbox: null;
views: TProjectPublishViewsSettings;
inbox: string | null;
}
export interface IProjectPublishStore {

View File

@ -3,6 +3,7 @@ import { enableStaticRendering } from "mobx-react-lite";
// store imports
import UserStore from "./user";
import ThemeStore from "./theme";
import IssuesStore from "./issues";
import ProjectPublishStore, { IProjectPublishStore } from "./project-publish";
enableStaticRendering(typeof window === "undefined");
@ -11,10 +12,12 @@ export class RootStore {
user;
theme;
projectPublish: IProjectPublishStore;
issues: IssuesStore;
constructor() {
this.user = new UserStore(this);
this.theme = new ThemeStore(this);
this.projectPublish = new ProjectPublishStore(this);
this.issues = new IssuesStore(this);
}
}

View File

@ -30,6 +30,10 @@
}
}
.ProseMirror-gapcursor:after {
border-top: 1px solid rgb(var(--color-text-100)) !important;
}
/* Custom TODO list checkboxes shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */
ul[data-type="taskList"] li > label {
@ -140,7 +144,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
height: 20px;
border-radius: 50%;
border: 3px solid rgba(var(--color-text-200));
border-top-color: rgba(var(--color-text-800));
border-top-color: rgba(var(--color-text-800));
animation: spinning 0.6s linear infinite;
}
}
@ -150,3 +154,78 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
transform: rotate(360deg);
}
}
#tiptap-container {
table {
border-collapse: collapse;
table-layout: fixed;
margin: 0;
border: 1px solid rgb(var(--color-border-200));
width: 100%;
td,
th {
min-width: 1em;
border: 1px solid rgb(var(--color-border-200));
padding: 10px 15px;
vertical-align: top;
box-sizing: border-box;
position: relative;
transition: background-color 0.3s ease;
> * {
margin-bottom: 0;
}
}
th {
font-weight: bold;
text-align: left;
background-color: rgb(var(--color-primary-100));
}
td:hover {
background-color: rgba(var(--color-primary-300), 0.1);
}
.selectedCell:after {
z-index: 2;
position: absolute;
content: "";
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(var(--color-primary-300), 0.1);
pointer-events: none;
}
.column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 2px;
background-color: rgb(var(--color-primary-400));
pointer-events: none;
}
}
}
.tableWrapper {
overflow-x: auto;
}
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
.ProseMirror table * p {
padding: 0px 1px;
margin: 6px 2px;
}
.ProseMirror table * .is-empty::before {
opacity: 0;
}

View File

@ -355,3 +355,7 @@ body {
.bp4-overlay-content {
z-index: 555 !important;
}
.disable-scroll {
overflow: hidden !important;
}

View File

@ -198,6 +198,7 @@ export interface IIssueActivity {
}
export interface IIssueComment extends IIssueActivity {
access: "EXTERNAL" | "INTERNAL";
comment_html: string;
comment_json: any;
comment_stripped: string;

View File

@ -10,6 +10,14 @@ import type {
linkDetails,
} from "types";
export type TModuleStatus =
| "backlog"
| "planned"
| "in-progress"
| "paused"
| "completed"
| "cancelled";
export interface IModule {
backlog_issues: number;
cancelled_issues: number;
@ -39,7 +47,7 @@ export interface IModule {
sort_order: number;
start_date: string | null;
started_issues: number;
status: "backlog" | "planned" | "in-progress" | "paused" | "completed" | "cancelled" | null;
status: TModuleStatus;
target_date: string | null;
total_issues: number;
unstarted_issues: number;

View File

@ -39,6 +39,7 @@ export interface IProject {
} | null;
id: string;
identifier: string;
is_deployed: boolean;
is_favorite: boolean;
is_member: boolean;
member_role: 5 | 10 | 15 | 20 | null;
@ -57,7 +58,6 @@ export interface IProject {
updated_by: string;
workspace: IWorkspace | string;
workspace_detail: IWorkspaceLite;
is_deployed: boolean;
}
export interface IProjectLite {

View File

@ -36,6 +36,7 @@ export interface IUser {
theme: ICustomTheme;
updated_at: readonly Date;
username: string;
user_timezone: string;
[...rest: string]: any;
}

View File

@ -0,0 +1,10 @@
"use client";
export const IssueBlockDownVotes = ({ number }: { number: number }) => (
<div className="h-6 rounded flex px-1.5 pl-1 py-1 items-center border-[0.5px] border-custom-border-300 text-custom-text-300 text-xs">
<span className="material-symbols-rounded text-base !p-0 !m-0 rotate-180 text-custom-text-300">
arrow_upward_alt
</span>
{number}
</div>
);

View File

@ -1,32 +1,60 @@
"use client";
// helpers
import { renderDateFormat } from "constants/helpers";
import { renderFullDate } from "constants/helpers";
export const findHowManyDaysLeft = (date: string | Date) => {
const today = new Date();
const eventDate = new Date(date);
const timeDiff = Math.abs(eventDate.getTime() - today.getTime());
return Math.ceil(timeDiff / (1000 * 3600 * 24));
};
const validDate = (date: any, state: string): string => {
if (date === null || ["backlog", "unstarted", "cancelled"].includes(state))
return `bg-gray-500/10 text-gray-500 border-gray-500/50`;
else {
const dueDateIcon = (
date: string,
stateGroup: string
): {
iconName: string;
className: string;
} => {
let iconName = "calendar_today";
let className = "";
if (!date || ["completed", "cancelled"].includes(stateGroup)) {
iconName = "calendar_today";
className = "";
} else {
const today = new Date();
const dueDate = new Date(date);
if (dueDate < today) return `bg-red-500/10 text-red-500 border-red-500/50`;
else return `bg-green-500/10 text-green-500 border-green-500/50`;
if (dueDate < today) {
iconName = "event_busy";
className = "text-red-500";
} else if (dueDate > today) {
iconName = "calendar_today";
className = "";
} else {
iconName = "today";
className = "text-red-500";
}
}
return {
iconName,
className,
};
};
export const IssueBlockDueDate = ({ due_date, state }: any) => (
<div
className={`h-[24px] rounded-md flex px-2.5 py-1 items-center border border-custom-border-100 gap-1 text-custom-text-100 text-xs font-medium
${validDate(due_date, state)}`}
>
{renderDateFormat(due_date)}
</div>
);
export const IssueBlockDueDate = ({ due_date, group }: { due_date: string; group: string }) => {
const iconDetails = dueDateIcon(due_date, group);
return (
<div className="rounded flex px-2.5 py-1 items-center border-[0.5px] border-custom-border-300 gap-1 text-custom-text-100 text-xs">
<span className={`material-symbols-rounded text-sm -my-0.5 ${iconDetails.className}`}>
{iconDetails.iconName}
</span>
{renderFullDate(due_date)}
</div>
);
};

View File

@ -9,8 +9,9 @@ export const IssueBlockPriority = ({ priority }: { priority: TIssuePriorityKey |
const priority_detail = priority != null ? issuePriorityFilter(priority) : null;
if (priority_detail === null) return <></>;
return (
<div className={`h-6 w-6 rounded flex justify-center items-center ${priority_detail?.className}`}>
<div className={`h-6 w-6 rounded grid place-items-center border-[0.5px] ${priority_detail?.className}`}>
<span className="material-symbols-rounded text-sm">{priority_detail?.icon}</span>
</div>
);

View File

@ -8,8 +8,8 @@ export const IssueBlockState = ({ state }: any) => {
if (stateGroup === null) return <></>;
return (
<div className="flex items-center justify-between gap-1 w-full rounded-md shadow-sm border border-custom-border-300 duration-300 focus:outline-none px-2.5 py-1 text-xs cursor-pointer hover:bg-custom-background-80">
<div className="flex items-center cursor-pointer w-full gap-2 text-custom-text-200">
<div className="flex items-center justify-between gap-1 w-full rounded shadow-sm border-[0.5px] border-custom-border-300 duration-300 focus:outline-none px-2.5 py-1 text-xs cursor-pointer hover:bg-custom-background-80">
<div className="flex items-center cursor-pointer w-full gap-1.5 text-custom-text-200">
<stateGroup.icon />
<div className="text-xs">{state?.name}</div>
</div>

View File

@ -0,0 +1,8 @@
"use client";
export const IssueBlockUpVotes = ({ number }: { number: number }) => (
<div className="h-6 rounded flex px-1.5 pl-1 py-1 items-center border-[0.5px] border-custom-border-300 text-custom-text-300 text-xs">
<span className="material-symbols-rounded text-base !p-0 !m-0 text-custom-text-300">arrow_upward_alt</span>
{number}
</div>
);

View File

@ -12,29 +12,45 @@ import { IssueBlockDueDate } from "components/issues/board-views/block-due-date"
// interfaces
import { IIssue } from "types/issue";
import { RootStore } from "store/root";
import { useRouter } from "next/router";
export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => {
const store: RootStore = useMobxStore();
const { project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore();
const { issue: issueStore } = store;
// router
const router = useRouter();
const { workspace_slug, project_slug, board } = router.query;
const handleBlockClick = () => {
issueDetailStore.setPeekId(issue.id);
router.replace(
{
pathname: `/${workspace_slug?.toString()}/${project_slug}`,
query: {
board: board?.toString(),
peekId: issue.id,
},
},
undefined,
{ shallow: true }
);
// router.push(`/${workspace_slug?.toString()}/${project_slug}?board=${board?.toString()}&peekId=${issue.id}`);
};
return (
<div className="p-3.5 h-[118px] flex flex-col justify-between bg-custom-background-100 space-y-2 rounded shadow">
<div className="py-3 px-4 h-[118px] flex flex-col gap-1.5 bg-custom-background-100 rounded shadow-custom-shadow-sm border-[0.5px] border-custom-border-200">
{/* id */}
<div className="flex-shrink-0 text-xs font-medium text-custom-text-200 w-[60px]">
{store?.project?.project?.identifier}-{issue?.sequence_id}
<div className="text-xs text-custom-text-300 break-words">
{projectStore?.project?.identifier}-{issue?.sequence_id}
</div>
{/* name */}
<div
onClick={() => issueStore?.setActivePeekOverviewIssueId(issue?.id)}
className="text-custom-text-100 text-sm font-medium h-full break-words line-clamp-2 cursor-pointer"
>
<h6 onClick={handleBlockClick} className="text-sm font-medium break-words line-clamp-2 cursor-pointer">
{issue.name}
</div>
</h6>
{/* priority */}
<div className="relative flex flex-wrap items-center gap-2 w-full">
<div className="relative flex-grow flex items-end gap-2 w-full overflow-x-scroll hide-horizontal-scrollbar">
{/* priority */}
{issue?.priority && (
<div className="flex-shrink-0">
<IssueBlockPriority priority={issue?.priority} />
@ -46,12 +62,6 @@ export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => {
<IssueBlockState state={issue?.state_detail} />
</div>
)}
{/* labels */}
{issue?.label_details && issue?.label_details.length > 0 && (
<div className="flex-shrink-0">
<IssueBlockLabels labels={issue?.label_details} />
</div>
)}
{/* due date */}
{issue?.target_date && (
<div className="flex-shrink-0">

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