forked from github/plane
fix: merge conflict
This commit is contained in:
commit
fd9d76f15f
@ -59,8 +59,9 @@ AWS_S3_BUCKET_NAME="uploads"
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
# GPT settings
|
||||
OPENAI_API_KEY=""
|
||||
GPT_ENGINE=""
|
||||
OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
|
||||
OPENAI_API_KEY="sk-" # add your openai key here
|
||||
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
|
||||
|
||||
# Github
|
||||
GITHUB_CLIENT_SECRET="" # For fetching release notes
|
||||
|
@ -1,2 +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 -
|
||||
worker: celery -A plane worker -l info
|
||||
beat: celery -A plane beat -l INFO
|
@ -21,6 +21,7 @@ from .project import (
|
||||
ProjectIdentifierSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
ProjectLiteSerializer,
|
||||
ProjectMemberLiteSerializer,
|
||||
)
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
|
||||
@ -41,6 +42,7 @@ from .issue import (
|
||||
IssueLinkSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueSubscriberSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
@ -74,4 +76,7 @@ from .estimate import (
|
||||
)
|
||||
|
||||
from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer
|
||||
|
||||
from .analytic import AnalyticViewSerializer
|
||||
|
||||
from .notification import NotificationSerializer
|
||||
|
@ -19,6 +19,7 @@ from plane.db.models import (
|
||||
IssueProperty,
|
||||
IssueBlocker,
|
||||
IssueAssignee,
|
||||
IssueSubscriber,
|
||||
IssueLabel,
|
||||
Label,
|
||||
IssueBlocker,
|
||||
@ -530,3 +531,14 @@ class IssueLiteSerializer(BaseSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class IssueSubscriberSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueSubscriber
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
]
|
||||
|
10
apiserver/plane/api/serializers/notification.py
Normal file
10
apiserver/plane/api/serializers/notification.py
Normal file
@ -0,0 +1,10 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import Notification
|
||||
|
||||
class NotificationSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = "__all__"
|
||||
|
@ -134,3 +134,20 @@ class ProjectFavoriteSerializer(BaseSerializer):
|
||||
"workspace",
|
||||
"user",
|
||||
]
|
||||
|
||||
|
||||
class ProjectLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = ["id", "identifier", "name"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ProjectMemberLiteSerializer(BaseSerializer):
|
||||
member = UserLiteSerializer(read_only=True)
|
||||
is_subscribed = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
fields = ["member", "id", "is_subscribed"]
|
||||
read_only_fields = fields
|
||||
|
@ -22,6 +22,7 @@ from plane.api.views import (
|
||||
# User
|
||||
UserEndpoint,
|
||||
UpdateUserOnBoardedEndpoint,
|
||||
UpdateUserTourCompletedEndpoint,
|
||||
UserActivityEndpoint,
|
||||
## End User
|
||||
# Workspaces
|
||||
@ -76,6 +77,8 @@ from plane.api.views import (
|
||||
IssueLinkViewSet,
|
||||
BulkCreateIssueLabelsEndpoint,
|
||||
IssueAttachmentEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
## End Issues
|
||||
# States
|
||||
StateViewSet,
|
||||
@ -148,6 +151,9 @@ from plane.api.views import (
|
||||
ExportAnalyticsEndpoint,
|
||||
DefaultAnalyticsEndpoint,
|
||||
## End Analytics
|
||||
# Notification
|
||||
NotificationViewSet,
|
||||
## End Notification
|
||||
)
|
||||
|
||||
|
||||
@ -197,7 +203,12 @@ urlpatterns = [
|
||||
path(
|
||||
"users/me/onboard/",
|
||||
UpdateUserOnBoardedEndpoint.as_view(),
|
||||
name="change-password",
|
||||
name="user-onboard",
|
||||
),
|
||||
path(
|
||||
"users/me/tour-completed/",
|
||||
UpdateUserTourCompletedEndpoint.as_view(),
|
||||
name="user-tour",
|
||||
),
|
||||
path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
|
||||
# user workspaces
|
||||
@ -467,7 +478,6 @@ urlpatterns = [
|
||||
"workspaces/<str:slug>/user-favorite-projects/",
|
||||
ProjectFavoritesViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
@ -797,6 +807,34 @@ urlpatterns = [
|
||||
name="project-issue-comment",
|
||||
),
|
||||
## End IssueComments
|
||||
# Issue Subscribers
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-subscribers/",
|
||||
IssueSubscriberViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-issue-subscribers",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-subscribers/<uuid:subscriber_id>/",
|
||||
IssueSubscriberViewSet.as_view({"delete": "destroy"}),
|
||||
name="project-issue-subscribers",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/subscribe/",
|
||||
IssueSubscriberViewSet.as_view(
|
||||
{
|
||||
"get": "subscription_status",
|
||||
"post": "subscribe",
|
||||
"delete": "unsubscribe",
|
||||
}
|
||||
),
|
||||
name="project-issue-subscribers",
|
||||
),
|
||||
## End Issue Subscribers
|
||||
## IssueProperty
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/",
|
||||
@ -821,6 +859,36 @@ urlpatterns = [
|
||||
name="project-issue-roadmap",
|
||||
),
|
||||
## IssueProperty Ebd
|
||||
## Issue Archives
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/",
|
||||
IssueArchiveViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
name="project-issue-archive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/<uuid:pk>/",
|
||||
IssueArchiveViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="project-issue-archive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/unarchive/<uuid:pk>/",
|
||||
IssueArchiveViewSet.as_view(
|
||||
{
|
||||
"post": "unarchive",
|
||||
}
|
||||
),
|
||||
name="project-issue-archive",
|
||||
),
|
||||
## End Issue Archives
|
||||
## File Assets
|
||||
path(
|
||||
"workspaces/<str:slug>/file-assets/",
|
||||
@ -1273,4 +1341,46 @@ urlpatterns = [
|
||||
name="default-analytics",
|
||||
),
|
||||
## End Analytics
|
||||
# Notification
|
||||
path(
|
||||
"workspaces/<str:slug>/users/notifications/",
|
||||
NotificationViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
}
|
||||
),
|
||||
name="notifications",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/users/notifications/<uuid:pk>/",
|
||||
NotificationViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="notifications",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/users/notifications/<uuid:pk>/read/",
|
||||
NotificationViewSet.as_view(
|
||||
{
|
||||
"post": "mark_read",
|
||||
"delete": "mark_unread",
|
||||
}
|
||||
),
|
||||
name="notifications",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/users/notifications/<uuid:pk>/archive/",
|
||||
NotificationViewSet.as_view(
|
||||
{
|
||||
"post": "archive",
|
||||
"delete": "unarchive",
|
||||
}
|
||||
),
|
||||
name="notifications",
|
||||
),
|
||||
## End Notification
|
||||
]
|
||||
|
@ -16,6 +16,7 @@ from .project import (
|
||||
from .people import (
|
||||
UserEndpoint,
|
||||
UpdateUserOnBoardedEndpoint,
|
||||
UpdateUserTourCompletedEndpoint,
|
||||
UserActivityEndpoint,
|
||||
)
|
||||
|
||||
@ -65,6 +66,8 @@ from .issue import (
|
||||
IssueLinkViewSet,
|
||||
BulkCreateIssueLabelsEndpoint,
|
||||
IssueAttachmentEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
)
|
||||
|
||||
from .auth_extended import (
|
||||
@ -133,6 +136,7 @@ from .estimate import (
|
||||
from .release import ReleaseNotesEndpoint
|
||||
|
||||
from .inbox import InboxViewSet, InboxIssueViewSet
|
||||
|
||||
from .analytic import (
|
||||
AnalyticsEndpoint,
|
||||
AnalyticViewViewset,
|
||||
@ -140,3 +144,5 @@ from .analytic import (
|
||||
ExportAnalyticsEndpoint,
|
||||
DefaultAnalyticsEndpoint,
|
||||
)
|
||||
|
||||
from .notification import NotificationViewSet
|
@ -345,7 +345,7 @@ class MagicSignInEndpoint(BaseAPIView):
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
user_token = request.data.get("token", "").strip().lower()
|
||||
user_token = request.data.get("token", "").strip()
|
||||
key = request.data.get("key", False)
|
||||
|
||||
if not key or user_token == "":
|
||||
|
@ -67,7 +67,7 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
|
||||
openai.api_key = settings.OPENAI_API_KEY
|
||||
response = openai.Completion.create(
|
||||
engine=settings.GPT_ENGINE,
|
||||
model=settings.GPT_ENGINE,
|
||||
prompt=final_text,
|
||||
temperature=0.7,
|
||||
max_tokens=1024,
|
||||
|
@ -15,6 +15,7 @@ from django.db.models import (
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
)
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
@ -43,11 +44,15 @@ from plane.api.serializers import (
|
||||
IssueLinkSerializer,
|
||||
IssueLiteSerializer,
|
||||
IssueAttachmentSerializer,
|
||||
IssueSubscriberSerializer,
|
||||
ProjectMemberSerializer,
|
||||
ProjectMemberLiteSerializer,
|
||||
)
|
||||
from plane.api.permissions import (
|
||||
ProjectEntityPermission,
|
||||
WorkSpaceAdminPermission,
|
||||
ProjectMemberPermission,
|
||||
ProjectLitePermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
@ -59,6 +64,8 @@ from plane.db.models import (
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
State,
|
||||
IssueSubscriber,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
@ -905,3 +912,347 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class IssueArchiveViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
serializer_class = IssueFlatSerializer
|
||||
model = Issue
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.objects.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("state")
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
show_sub_issues = request.GET.get("show_sub_issues", "true")
|
||||
|
||||
# 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 = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__id"))
|
||||
.annotate(module_id=F("issue_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)
|
||||
|
||||
issue_queryset = (
|
||||
issue_queryset
|
||||
if show_sub_issues == "true"
|
||||
else issue_queryset.filter(parent__isnull=True)
|
||||
)
|
||||
|
||||
issues = IssueLiteSerializer(issue_queryset, many=True).data
|
||||
|
||||
## Grouping the results
|
||||
group_by = request.GET.get("group_by", False)
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(issues, group_by), status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
try:
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
archived_at__isnull=False,
|
||||
pk=pk,
|
||||
)
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
except Issue.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def unarchive(self, request, slug, project_id, pk=None):
|
||||
try:
|
||||
issue = Issue.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
archived_at__isnull=False,
|
||||
pk=pk,
|
||||
)
|
||||
issue.archived_at = None
|
||||
issue.save()
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"archived_at": None}),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
)
|
||||
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
except Issue.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong, please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
class IssueSubscriberViewSet(BaseViewSet):
|
||||
serializer_class = IssueSubscriberSerializer
|
||||
model = IssueSubscriber
|
||||
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ["subscribe", "unsubscribe", "subscription_status"]:
|
||||
self.permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
else:
|
||||
self.permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
return super(IssueSubscriberViewSet, self).get_permissions()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
issue_id=self.kwargs.get("issue_id"),
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
members = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).annotate(
|
||||
is_subscribed=Exists(
|
||||
IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
subscriber=OuterRef("member"),
|
||||
)
|
||||
)
|
||||
).select_related("member")
|
||||
serializer = ProjectMemberLiteSerializer(members, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": e},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, issue_id, subscriber_id):
|
||||
try:
|
||||
issue_subscriber = IssueSubscriber.objects.get(
|
||||
project=project_id,
|
||||
subscriber=subscriber_id,
|
||||
workspace__slug=slug,
|
||||
issue=issue_id,
|
||||
)
|
||||
issue_subscriber.delete()
|
||||
return Response(
|
||||
status=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
except IssueSubscriber.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "User is not subscribed to this issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def subscribe(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
if IssueSubscriber.objects.filter(
|
||||
issue_id=issue_id,
|
||||
subscriber=request.user,
|
||||
workspace__slug=slug,
|
||||
project=project_id,
|
||||
).exists():
|
||||
return Response(
|
||||
{"message": "User already subscribed to the issue."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
subscriber = IssueSubscriber.objects.create(
|
||||
issue_id=issue_id,
|
||||
subscriber_id=request.user.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
serilaizer = IssueSubscriberSerializer(subscriber)
|
||||
return Response(serilaizer.data, status=status.HTTP_201_CREATED)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong, please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def unsubscribe(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
issue_subscriber = IssueSubscriber.objects.get(
|
||||
project=project_id,
|
||||
subscriber=request.user,
|
||||
workspace__slug=slug,
|
||||
issue=issue_id,
|
||||
)
|
||||
issue_subscriber.delete()
|
||||
return Response(
|
||||
status=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
except IssueSubscriber.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "User subscribed to this issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def subscription_status(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
issue_subscriber = IssueSubscriber.objects.filter(
|
||||
issue=issue_id,
|
||||
subscriber=request.user,
|
||||
workspace__slug=slug,
|
||||
project=project_id,
|
||||
).exists()
|
||||
return Response({"subscribed": issue_subscriber}, 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,
|
||||
)
|
||||
|
211
apiserver/plane/api/views/notification.py
Normal file
211
apiserver/plane/api/views/notification.py
Normal file
@ -0,0 +1,211 @@
|
||||
# Django imports
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet
|
||||
from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue
|
||||
from plane.api.serializers import NotificationSerializer
|
||||
|
||||
|
||||
class NotificationViewSet(BaseViewSet):
|
||||
model = Notification
|
||||
serializer_class = NotificationSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
receiver_id=self.request.user.id,
|
||||
)
|
||||
.select_related("workspace")
|
||||
)
|
||||
|
||||
def list(self, request, slug):
|
||||
try:
|
||||
order_by = request.GET.get("order_by", "-created_at")
|
||||
snoozed = request.GET.get("snoozed", "false")
|
||||
archived = request.GET.get("archived", "false")
|
||||
read = request.GET.get("read", "false")
|
||||
|
||||
# Filter type
|
||||
type = request.GET.get("type", "all")
|
||||
|
||||
notifications = Notification.objects.filter(
|
||||
workspace__slug=slug, receiver_id=request.user.id
|
||||
).order_by(order_by)
|
||||
|
||||
# Filter for snoozed notifications
|
||||
if snoozed == "false":
|
||||
notifications = notifications.filter(
|
||||
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
|
||||
)
|
||||
|
||||
if snoozed == "true":
|
||||
notifications = notifications.filter(
|
||||
Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False)
|
||||
)
|
||||
|
||||
if read == "true":
|
||||
notifications = notifications.filter(read_at__isnull=False)
|
||||
if read == "false":
|
||||
notifications = notifications.filter(read_at__isnull=True)
|
||||
|
||||
# Filter for archived or unarchive
|
||||
if archived == "false":
|
||||
notifications = notifications.filter(archived_at__isnull=True)
|
||||
|
||||
if archived == "true":
|
||||
notifications = notifications.filter(archived_at__isnull=False)
|
||||
|
||||
# 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":
|
||||
issue_ids = Issue.objects.filter(
|
||||
workspace__slug=slug, created_by=request.user
|
||||
).values_list("pk", flat=True)
|
||||
notifications = notifications.filter(entity_identifier__in=issue_ids)
|
||||
|
||||
serializer = NotificationSerializer(notifications, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, pk):
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
workspace__slug=slug, pk=pk, receiver=request.user
|
||||
)
|
||||
# Only read_at and snoozed_till can be updated
|
||||
notification_data = {
|
||||
"snoozed_till": request.data.get("snoozed_till", None),
|
||||
}
|
||||
serializer = NotificationSerializer(
|
||||
notification, data=notification_data, partial=True
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Notification does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def mark_read(self, request, slug, pk):
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
receiver=request.user, workspace__slug=slug, pk=pk
|
||||
)
|
||||
notification.read_at = timezone.now()
|
||||
notification.save()
|
||||
serializer = NotificationSerializer(notification)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Notification does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def mark_unread(self, request, slug, pk):
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
receiver=request.user, workspace__slug=slug, pk=pk
|
||||
)
|
||||
notification.read_at = None
|
||||
notification.save()
|
||||
serializer = NotificationSerializer(notification)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Notification does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
def archive(self, request, slug, pk):
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
receiver=request.user, workspace__slug=slug, pk=pk
|
||||
)
|
||||
notification.archived_at = timezone.now()
|
||||
notification.save()
|
||||
serializer = NotificationSerializer(notification)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Notification does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def unarchive(self, request, slug, pk):
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
receiver=request.user, workspace__slug=slug, pk=pk
|
||||
)
|
||||
notification.archived_at = None
|
||||
notification.save()
|
||||
serializer = NotificationSerializer(notification)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Notification does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
@ -37,7 +37,9 @@ class UserEndpoint(BaseViewSet):
|
||||
workspace_invites = WorkspaceMemberInvite.objects.filter(
|
||||
email=request.user.email
|
||||
).count()
|
||||
assigned_issues = Issue.issue_objects.filter(assignees__in=[request.user]).count()
|
||||
assigned_issues = Issue.issue_objects.filter(
|
||||
assignees__in=[request.user]
|
||||
).count()
|
||||
|
||||
serialized_data = UserSerializer(request.user).data
|
||||
serialized_data["workspace"] = {
|
||||
@ -47,7 +49,9 @@ class UserEndpoint(BaseViewSet):
|
||||
"fallback_workspace_slug": workspace.slug,
|
||||
"invites": workspace_invites,
|
||||
}
|
||||
serialized_data.setdefault("issues", {})["assigned_issues"] = assigned_issues
|
||||
serialized_data.setdefault("issues", {})[
|
||||
"assigned_issues"
|
||||
] = assigned_issues
|
||||
|
||||
return Response(
|
||||
serialized_data,
|
||||
@ -59,11 +63,15 @@ class UserEndpoint(BaseViewSet):
|
||||
workspace_invites = WorkspaceMemberInvite.objects.filter(
|
||||
email=request.user.email
|
||||
).count()
|
||||
assigned_issues = Issue.issue_objects.filter(assignees__in=[request.user]).count()
|
||||
assigned_issues = Issue.issue_objects.filter(
|
||||
assignees__in=[request.user]
|
||||
).count()
|
||||
|
||||
fallback_workspace = Workspace.objects.filter(
|
||||
workspace_member__member=request.user
|
||||
).order_by("created_at").first()
|
||||
fallback_workspace = (
|
||||
Workspace.objects.filter(workspace_member__member=request.user)
|
||||
.order_by("created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
serialized_data = UserSerializer(request.user).data
|
||||
|
||||
@ -78,7 +86,9 @@ class UserEndpoint(BaseViewSet):
|
||||
else None,
|
||||
"invites": workspace_invites,
|
||||
}
|
||||
serialized_data.setdefault("issues", {})["assigned_issues"] = assigned_issues
|
||||
serialized_data.setdefault("issues", {})[
|
||||
"assigned_issues"
|
||||
] = assigned_issues
|
||||
|
||||
return Response(
|
||||
serialized_data,
|
||||
@ -109,6 +119,23 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
|
||||
class UpdateUserTourCompletedEndpoint(BaseAPIView):
|
||||
def patch(self, request):
|
||||
try:
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
user.is_tour_completed = request.data.get("is_tour_completed", False)
|
||||
user.save()
|
||||
return Response(
|
||||
{"message": "Updated successfully"}, status=status.HTTP_200_OK
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class UserActivityEndpoint(BaseAPIView, BasePaginator):
|
||||
def get(self, request):
|
||||
try:
|
||||
|
@ -96,6 +96,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
def list(self, request, slug):
|
||||
try:
|
||||
is_favorite = request.GET.get("is_favorite", "all")
|
||||
subquery = ProjectFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
@ -126,6 +127,12 @@ class ProjectViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
if is_favorite == "true":
|
||||
projects = projects.filter(is_favorite=True)
|
||||
if is_favorite == "false":
|
||||
projects = projects.filter(is_favorite=False)
|
||||
|
||||
return Response(ProjectDetailSerializer(projects, many=True).data)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
@ -5,6 +5,7 @@ import requests
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
@ -20,6 +21,9 @@ from plane.db.models import (
|
||||
State,
|
||||
Cycle,
|
||||
Module,
|
||||
IssueSubscriber,
|
||||
Notification,
|
||||
IssueAssignee,
|
||||
)
|
||||
from plane.api.serializers import IssueActivitySerializer
|
||||
|
||||
@ -554,6 +558,64 @@ def track_estimate_points(
|
||||
)
|
||||
|
||||
|
||||
def track_archive_at(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
if requested_data.get("archived_at") is None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"{actor.email} has restored the issue",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
field="archived_at",
|
||||
old_value="archive",
|
||||
new_value="restore",
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"Plane has archived the issue",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
field="archived_at",
|
||||
old_value=None,
|
||||
new_value="archive",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def track_closed_to(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
if requested_data.get("closed_to") is not None:
|
||||
updated_state = State.objects.get(
|
||||
pk=requested_data.get("closed_to"), project=project
|
||||
)
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=None,
|
||||
new_value=updated_state.name,
|
||||
field="state",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"Plane updated the state to {updated_state.name}",
|
||||
old_identifier=None,
|
||||
new_identifier=updated_state.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
@ -570,6 +632,8 @@ def update_issue_activity(
|
||||
"blocks_list": track_blocks,
|
||||
"blockers_list": track_blockings,
|
||||
"estimate_point": track_estimate_points,
|
||||
"archived_at": track_archive_at,
|
||||
"closed_to": track_closed_to,
|
||||
}
|
||||
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
@ -950,7 +1014,13 @@ def delete_attachment_activity(
|
||||
# Receive message from room group
|
||||
@shared_task
|
||||
def issue_activity(
|
||||
type, requested_data, current_instance, issue_id, actor_id, project_id
|
||||
type,
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
actor_id,
|
||||
project_id,
|
||||
subscriber=True,
|
||||
):
|
||||
try:
|
||||
issue_activities = []
|
||||
@ -958,6 +1028,20 @@ def issue_activity(
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
if issue is not None:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save()
|
||||
|
||||
if subscriber:
|
||||
# add the user to issue subscriber
|
||||
try:
|
||||
_ = IssueSubscriber.objects.get_or_create(
|
||||
issue_id=issue_id, subscriber=actor
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
ACTIVITY_MAPPER = {
|
||||
"issue.activity.created": create_issue_activity,
|
||||
"issue.activity.updated": update_issue_activity,
|
||||
@ -992,18 +1076,72 @@ def issue_activity(
|
||||
# Post the updates to segway for integrations and webhooks
|
||||
if len(issue_activities_created):
|
||||
# Don't send activities if the actor is a bot
|
||||
if settings.PROXY_BASE_URL:
|
||||
for issue_activity in issue_activities_created:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
issue_activity_json = json.dumps(
|
||||
IssueActivitySerializer(issue_activity).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
)
|
||||
_ = requests.post(
|
||||
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/",
|
||||
json=issue_activity_json,
|
||||
headers=headers,
|
||||
try:
|
||||
if settings.PROXY_BASE_URL:
|
||||
for issue_activity in issue_activities_created:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
issue_activity_json = json.dumps(
|
||||
IssueActivitySerializer(issue_activity).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
)
|
||||
_ = requests.post(
|
||||
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/",
|
||||
json=issue_activity_json,
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(project=project, issue_id=issue_id)
|
||||
.exclude(subscriber_id=actor_id)
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
|
||||
issue_assignees = list(
|
||||
IssueAssignee.objects.filter(project=project, issue_id=issue_id)
|
||||
.exclude(assignee_id=actor_id)
|
||||
.values_list("assignee", flat=True)
|
||||
)
|
||||
|
||||
issue_subscribers = issue_subscribers + issue_assignees
|
||||
|
||||
if issue.created_by_id:
|
||||
issue_subscribers = issue_subscribers + [issue.created_by_id]
|
||||
|
||||
issue = Issue.objects.get(project=project, pk=issue_id)
|
||||
for subscriber in issue_subscribers:
|
||||
for issue_activity in issue_activities_created:
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
title=issue_activity.comment,
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": str(issue_activity.id),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk create notifications
|
||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
|
147
apiserver/plane/bgtasks/issue_automation_task.py
Normal file
147
apiserver/plane/bgtasks/issue_automation_task.py
Normal file
@ -0,0 +1,147 @@
|
||||
# Python imports
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue, Project, State
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
|
||||
|
||||
@shared_task
|
||||
def archive_and_close_old_issues():
|
||||
archive_old_issues()
|
||||
close_old_issues()
|
||||
|
||||
|
||||
def archive_old_issues():
|
||||
try:
|
||||
# Get all the projects whose archive_in is greater than 0
|
||||
projects = Project.objects.filter(archive_in__gt=0)
|
||||
|
||||
for project in projects:
|
||||
project_id = project.id
|
||||
archive_in = project.archive_in
|
||||
|
||||
# Get all the issues whose updated_at in less that the archive_in month
|
||||
issues = Issue.objects.filter(
|
||||
Q(
|
||||
project=project_id,
|
||||
archived_at__isnull=True,
|
||||
updated_at__lte=(timezone.now() - timedelta(days=archive_in * 30)),
|
||||
state__group__in=["completed", "cancelled"],
|
||||
),
|
||||
Q(issue_cycle__isnull=True)
|
||||
| (
|
||||
Q(issue_cycle__cycle__end_date__lt=timezone.now().date())
|
||||
& Q(issue_cycle__isnull=False)
|
||||
),
|
||||
Q(issue_module__isnull=True)
|
||||
| (
|
||||
Q(issue_module__module__target_date__lt=timezone.now().date())
|
||||
& Q(issue_module__isnull=False)
|
||||
),
|
||||
)
|
||||
|
||||
# Check if Issues
|
||||
if issues:
|
||||
issues_to_update = []
|
||||
for issue in issues:
|
||||
issue.archived_at = timezone.now()
|
||||
issues_to_update.append(issue)
|
||||
|
||||
# Bulk Update the issues and log the activity
|
||||
Issue.objects.bulk_update(
|
||||
issues_to_update, ["archived_at"], batch_size=100
|
||||
)
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"archived_at": issue.archived_at}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
)
|
||||
for issue in issues_to_update
|
||||
]
|
||||
return
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
|
||||
def close_old_issues():
|
||||
try:
|
||||
# Get all the projects whose close_in is greater than 0
|
||||
projects = Project.objects.filter(close_in__gt=0).select_related(
|
||||
"default_state"
|
||||
)
|
||||
|
||||
for project in projects:
|
||||
project_id = project.id
|
||||
close_in = project.close_in
|
||||
|
||||
# Get all the issues whose updated_at in less that the close_in month
|
||||
issues = Issue.objects.filter(
|
||||
Q(
|
||||
project=project_id,
|
||||
archived_at__isnull=True,
|
||||
updated_at__lte=(timezone.now() - timedelta(days=close_in * 30)),
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
),
|
||||
Q(issue_cycle__isnull=True)
|
||||
| (
|
||||
Q(issue_cycle__cycle__end_date__lt=timezone.now().date())
|
||||
& Q(issue_cycle__isnull=False)
|
||||
),
|
||||
Q(issue_module__isnull=True)
|
||||
| (
|
||||
Q(issue_module__module__target_date__lt=timezone.now().date())
|
||||
& Q(issue_module__isnull=False)
|
||||
),
|
||||
)
|
||||
|
||||
# Check if Issues
|
||||
if issues:
|
||||
if project.default_state is None:
|
||||
close_state = State.objects.filter(group="cancelled").first()
|
||||
else:
|
||||
close_state = project.default_state
|
||||
|
||||
issues_to_update = []
|
||||
for issue in issues:
|
||||
issue.state = close_state
|
||||
issues_to_update.append(issue)
|
||||
|
||||
# Bulk Update the issues and log the activity
|
||||
Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"closed_to": issue.state_id}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
)
|
||||
for issue in issues_to_update
|
||||
]
|
||||
return
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
@ -1,6 +1,7 @@
|
||||
import os
|
||||
from celery import Celery
|
||||
from plane.settings.redis import redis_instance
|
||||
from celery.schedules import crontab
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
|
||||
@ -13,5 +14,15 @@ app = Celery("plane")
|
||||
# pickle the object when using Windows.
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
||||
app.conf.beat_schedule = {
|
||||
# Executes every day at 12 AM
|
||||
"check-every-day-to-archive-and-close": {
|
||||
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
|
||||
"schedule": crontab(hour=0, minute=0),
|
||||
},
|
||||
}
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler'
|
@ -33,6 +33,7 @@ from .issue import (
|
||||
IssueLink,
|
||||
IssueSequence,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
)
|
||||
|
||||
from .asset import FileAsset
|
||||
@ -66,4 +67,7 @@ from .page import Page, PageBlock, PageFavorite, PageLabel
|
||||
from .estimate import Estimate, EstimatePoint
|
||||
|
||||
from .inbox import Inbox, InboxIssue
|
||||
|
||||
from .analytic import AnalyticView
|
||||
|
||||
from .notification import Notification
|
@ -28,6 +28,8 @@ class IssueManager(models.Manager):
|
||||
| models.Q(issue_inbox__status=2)
|
||||
| models.Q(issue_inbox__isnull=True)
|
||||
)
|
||||
.filter(archived_at__isnull=True)
|
||||
.exclude(archived_at__isnull=False)
|
||||
)
|
||||
|
||||
|
||||
@ -81,6 +83,7 @@ class Issue(ProjectBaseModel):
|
||||
)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
completed_at = models.DateTimeField(null=True)
|
||||
archived_at = models.DateField(null=True)
|
||||
|
||||
objects = models.Manager()
|
||||
issue_objects = IssueManager()
|
||||
@ -401,6 +404,27 @@ class IssueSequence(ProjectBaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class IssueSubscriber(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
Issue, on_delete=models.CASCADE, related_name="issue_subscribers"
|
||||
)
|
||||
subscriber = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="issue_subscribers",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue", "subscriber"]
|
||||
verbose_name = "Issue Subscriber"
|
||||
verbose_name_plural = "Issue Subscribers"
|
||||
db_table = "issue_subscribers"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.subscriber.email}"
|
||||
|
||||
|
||||
# TODO: Find a better method to save the model
|
||||
@receiver(post_save, sender=Issue)
|
||||
def create_issue_sequence(sender, instance, created, **kwargs):
|
||||
|
37
apiserver/plane/db/models/notification.py
Normal file
37
apiserver/plane/db/models/notification.py
Normal file
@ -0,0 +1,37 @@
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Third party imports
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class Notification(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", related_name="notifications", on_delete=models.CASCADE
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"db.Project", related_name="notifications", on_delete=models.CASCADE, null=True
|
||||
)
|
||||
data = models.JSONField(null=True)
|
||||
entity_identifier = models.UUIDField(null=True)
|
||||
entity_name = models.CharField(max_length=255)
|
||||
title = models.TextField()
|
||||
message = models.JSONField(null=True)
|
||||
message_html = models.TextField(blank=True, default="<p></p>")
|
||||
message_stripped = models.TextField(blank=True, null=True)
|
||||
sender = models.CharField(max_length=255)
|
||||
triggered_by = models.ForeignKey("db.User", related_name="triggered_notifications", on_delete=models.SET_NULL, null=True)
|
||||
receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE)
|
||||
read_at = models.DateTimeField(null=True)
|
||||
snoozed_till = models.DateTimeField(null=True)
|
||||
archived_at = models.DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Notification"
|
||||
verbose_name_plural = "Notifications"
|
||||
db_table = "notifications"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the notifications"""
|
||||
return f"{self.receiver.email} <{self.workspace.name}>"
|
@ -4,6 +4,7 @@ from django.conf import settings
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
|
||||
# Modeule imports
|
||||
from plane.db.mixins import AuditModel
|
||||
@ -74,6 +75,15 @@ class Project(BaseModel):
|
||||
estimate = models.ForeignKey(
|
||||
"db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True
|
||||
)
|
||||
archive_in = models.IntegerField(
|
||||
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
|
||||
)
|
||||
close_in = models.IntegerField(
|
||||
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
|
||||
)
|
||||
default_state = models.ForeignKey(
|
||||
"db.State", on_delete=models.SET_NULL, null=True, related_name="default_state"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the project"""
|
||||
|
@ -18,6 +18,13 @@ from sentry_sdk import capture_exception
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
def get_default_onboarding():
|
||||
return {
|
||||
"profile_complete": False,
|
||||
"workspace_create": False,
|
||||
"workspace_invite": False,
|
||||
"workspace_join": False,
|
||||
}
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
id = models.UUIDField(
|
||||
@ -73,6 +80,8 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
role = models.CharField(max_length=300, null=True, blank=True)
|
||||
is_bot = models.BooleanField(default=False)
|
||||
theme = models.JSONField(default=dict)
|
||||
is_tour_completed = models.BooleanField(default=False)
|
||||
onboarding_step = models.JSONField(default=get_default_onboarding)
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
|
||||
|
@ -35,6 +35,7 @@ INSTALLED_APPS = [
|
||||
"rest_framework_simplejwt.token_blacklist",
|
||||
"corsheaders",
|
||||
"taggit",
|
||||
"django_celery_beat",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@ -213,3 +214,4 @@ SIMPLE_JWT = {
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_ACCEPT_CONTENT = ['application/json']
|
||||
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task",)
|
||||
|
@ -10,9 +10,7 @@ from sentry_sdk.integrations.redis import RedisIntegration
|
||||
|
||||
from .common import * # noqa
|
||||
|
||||
DEBUG = int(os.environ.get(
|
||||
"DEBUG", 1
|
||||
)) == 1
|
||||
DEBUG = int(os.environ.get("DEBUG", 1)) == 1
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
@ -27,13 +25,11 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
DOCKERIZED = int(os.environ.get(
|
||||
"DOCKERIZED", 0
|
||||
)) == 1
|
||||
DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1
|
||||
|
||||
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
|
||||
|
||||
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
|
||||
if DOCKERIZED:
|
||||
DATABASES["default"] = dj_database_url.config()
|
||||
@ -65,6 +61,27 @@ if os.environ.get("SENTRY_DSN", False):
|
||||
traces_sample_rate=0.7,
|
||||
profiles_sample_rate=1.0,
|
||||
)
|
||||
else:
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
},
|
||||
"loggers": {
|
||||
"*": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
"propagate": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
REDIS_HOST = "localhost"
|
||||
REDIS_PORT = 6379
|
||||
@ -83,8 +100,9 @@ PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||
|
||||
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
|
||||
|
||||
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
|
||||
|
||||
|
@ -246,8 +246,9 @@ PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||
|
||||
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
|
||||
|
||||
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
|
||||
|
||||
|
@ -11,10 +11,9 @@ from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
|
||||
from .common import * # noqa
|
||||
|
||||
# Database
|
||||
DEBUG = int(os.environ.get(
|
||||
"DEBUG", 1
|
||||
)) == 1
|
||||
DEBUG = int(os.environ.get("DEBUG", 1)) == 1
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
@ -56,9 +55,7 @@ STORAGES = {
|
||||
|
||||
|
||||
# Make true if running in a docker environment
|
||||
DOCKERIZED = int(os.environ.get(
|
||||
"DOCKERIZED", 0
|
||||
)) == 1
|
||||
DOCKERIZED = int(os.environ.get("DOCKERIZED", 0)) == 1
|
||||
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
|
||||
|
||||
@ -201,15 +198,19 @@ PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False)
|
||||
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
|
||||
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
|
||||
|
||||
|
||||
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "text-davinci-003")
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
|
||||
|
||||
SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False)
|
||||
|
||||
LOGGER_BASE_URL = os.environ.get("LOGGER_BASE_URL", False)
|
||||
|
||||
redis_url = os.environ.get("REDIS_URL")
|
||||
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||
broker_url = (
|
||||
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||
)
|
||||
|
||||
CELERY_RESULT_BACKEND = broker_url
|
||||
CELERY_BROKER_URL = broker_url
|
||||
|
@ -3,7 +3,7 @@
|
||||
Django==4.2.3
|
||||
django-braces==1.15.0
|
||||
django-taggit==4.0.0
|
||||
psycopg2==2.9.6
|
||||
psycopg==3.1.9
|
||||
django-oauth-toolkit==2.3.0
|
||||
mistune==3.0.1
|
||||
djangorestframework==3.14.0
|
||||
@ -29,3 +29,4 @@ channels==4.0.0
|
||||
openai==0.27.8
|
||||
slack-sdk==3.21.3
|
||||
celery==5.3.1
|
||||
django_celery_beat==2.5.0
|
||||
|
@ -61,7 +61,7 @@ export const CustomAnalytics: React.FC<Props> = ({
|
||||
<AnalyticsSelectBar
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
projects={projects}
|
||||
projects={projects ?? []}
|
||||
params={params}
|
||||
fullScreen={fullScreen}
|
||||
isProjectLevel={isProjectLevel}
|
||||
|
@ -24,13 +24,13 @@ import { ContrastIcon, LayerDiagonalIcon } from "components/icons";
|
||||
// helpers
|
||||
import { renderShortDate } from "helpers/date-time.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
// types
|
||||
import {
|
||||
IAnalyticsParams,
|
||||
IAnalyticsResponse,
|
||||
ICurrentUserResponse,
|
||||
IExportAnalyticsFormData,
|
||||
IProject,
|
||||
IWorkspace,
|
||||
} from "types";
|
||||
// fetch-keys
|
||||
@ -179,7 +179,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const selectedProjects =
|
||||
params.project && params.project.length > 0 ? params.project : projects.map((p) => p.id);
|
||||
params.project && params.project.length > 0 ? params.project : projects?.map((p) => p.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -207,7 +207,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full overflow-hidden">
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
{fullScreen ? (
|
||||
<>
|
||||
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
|
||||
@ -215,61 +215,62 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
<h4 className="font-medium">Selected Projects</h4>
|
||||
<div className="space-y-6 mt-4 h-full overflow-y-auto">
|
||||
{selectedProjects.map((projectId) => {
|
||||
const project: IProject = projects.find((p) => p.id === projectId);
|
||||
const project = projects?.find((p) => p.id === projectId);
|
||||
|
||||
return (
|
||||
<div key={project.id}>
|
||||
<div className="text-sm flex items-center gap-1">
|
||||
{project.emoji ? (
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
|
||||
{renderEmoji(project.emoji)}
|
||||
</span>
|
||||
) : project.icon_prop ? (
|
||||
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
|
||||
<span
|
||||
style={{ color: project.icon_prop.color }}
|
||||
className="material-symbols-rounded text-lg"
|
||||
>
|
||||
{project.icon_prop.name}
|
||||
if (project)
|
||||
return (
|
||||
<div key={project.id} className="w-full">
|
||||
<div className="text-sm flex items-center gap-1">
|
||||
{project.emoji ? (
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
|
||||
{renderEmoji(project.emoji)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{project?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<h5 className="break-words">
|
||||
{project.name}
|
||||
<span className="text-custom-text-200 text-xs ml-1">
|
||||
({project.identifier})
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 pl-2">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-custom-text-200" />
|
||||
<h6>Total members</h6>
|
||||
</div>
|
||||
<span className="text-custom-text-200">{project.total_members}</span>
|
||||
) : project.icon_prop ? (
|
||||
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
|
||||
<span
|
||||
style={{ color: project.icon_prop.color }}
|
||||
className="material-symbols-rounded text-lg"
|
||||
>
|
||||
{project.icon_prop.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{project?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<h5 className="flex items-center gap-1">
|
||||
<p className="break-words">{truncateText(project.name, 20)}</p>
|
||||
<span className="text-custom-text-200 text-xs ml-1">
|
||||
({project.identifier})
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<ContrastIcon height={16} width={16} />
|
||||
<h6>Total cycles</h6>
|
||||
<div className="mt-4 space-y-3 pl-2 w-full">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-custom-text-200" />
|
||||
<h6>Total members</h6>
|
||||
</div>
|
||||
<span className="text-custom-text-200">{project.total_members}</span>
|
||||
</div>
|
||||
<span className="text-custom-text-200">{project.total_cycles}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-custom-text-200" />
|
||||
<h6>Total modules</h6>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<ContrastIcon height={16} width={16} />
|
||||
<h6>Total cycles</h6>
|
||||
</div>
|
||||
<span className="text-custom-text-200">{project.total_cycles}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-custom-text-200" />
|
||||
<h6>Total modules</h6>
|
||||
</div>
|
||||
<span className="text-custom-text-200">{project.total_modules}</span>
|
||||
</div>
|
||||
<span className="text-custom-text-200">{project.total_modules}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
95
apps/app/components/automation/auto-archive-automation.tsx
Normal file
95
apps/app/components/automation/auto-archive-automation.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
// component
|
||||
import { CustomSelect, ToggleSwitch } from "components/ui";
|
||||
import { SelectMonthModal } from "components/automation";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
// constants
|
||||
import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
|
||||
// types
|
||||
import { IProject } from "types";
|
||||
|
||||
type Props = {
|
||||
projectDetails: IProject | undefined;
|
||||
handleChange: (formData: Partial<IProject>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => {
|
||||
const [monthModal, setmonthModal] = useState(false);
|
||||
|
||||
const initialValues: Partial<IProject> = { archive_in: 1 };
|
||||
return (
|
||||
<>
|
||||
<SelectMonthModal
|
||||
type="auto-archive"
|
||||
initialValues={initialValues}
|
||||
isOpen={monthModal}
|
||||
handleClose={() => setmonthModal(false)}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-brand-base bg-brand-base">
|
||||
<div className="flex items-center justify-between gap-x-8 gap-y-2 ">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h4 className="text-lg font-semibold">Auto-archive closed issues</h4>
|
||||
<p className="text-sm text-brand-secondary">
|
||||
Plane will automatically archive issues that have been completed or cancelled for the
|
||||
configured time period.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={projectDetails?.archive_in !== 0}
|
||||
onChange={() =>
|
||||
projectDetails?.archive_in === 0
|
||||
? handleChange({ archive_in: 1 })
|
||||
: handleChange({ archive_in: 0 })
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{projectDetails?.archive_in !== 0 && (
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="w-1/2 text-base font-medium">
|
||||
Auto-archive issues that are closed for
|
||||
</div>
|
||||
<div className="w-1/2 ">
|
||||
<CustomSelect
|
||||
value={projectDetails?.archive_in}
|
||||
customButton={
|
||||
<button className="flex w-full items-center justify-between gap-1 rounded-md border border-brand-base shadow-sm duration-300 text-brand-secondary hover:text-brand-base hover:bg-brand-surface-2 focus:outline-none px-3 py-2 text-sm text-left">
|
||||
{`${projectDetails?.archive_in} ${
|
||||
projectDetails?.archive_in === 1 ? "Month" : "Months"
|
||||
}`}
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ archive_in: val });
|
||||
}}
|
||||
input
|
||||
verticalPosition="top"
|
||||
width="w-full"
|
||||
>
|
||||
<>
|
||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||
<CustomSelect.Option key={month.label} value={month.value}>
|
||||
{month.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
Customize Time Range
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
190
apps/app/components/automation/auto-close-automation.tsx
Normal file
190
apps/app/components/automation/auto-close-automation.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// component
|
||||
import { CustomSearchSelect, CustomSelect, ToggleSwitch } from "components/ui";
|
||||
import { SelectMonthModal } from "components/automation";
|
||||
// icons
|
||||
import { ChevronDownIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
import { getStateGroupIcon } from "components/icons";
|
||||
// services
|
||||
import stateService from "services/state.service";
|
||||
// constants
|
||||
import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
|
||||
import { STATES_LIST } from "constants/fetch-keys";
|
||||
// types
|
||||
import { IProject } from "types";
|
||||
// helper
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
|
||||
type Props = {
|
||||
projectDetails: IProject | undefined;
|
||||
handleChange: (formData: Partial<IProject>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => {
|
||||
const [monthModal, setmonthModal] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: stateGroups } = useSWR(
|
||||
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => stateService.getStates(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const states = getStatesList(stateGroups ?? {});
|
||||
|
||||
const options = states
|
||||
?.filter((state) => state.group === "cancelled")
|
||||
.map((state) => ({
|
||||
value: state.id,
|
||||
query: state.name,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
{getStateGroupIcon(state.group, "16", "16", state.color)}
|
||||
{state.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const multipleOptions = options.length > 1;
|
||||
|
||||
const defaultState = stateGroups && stateGroups.cancelled ? stateGroups.cancelled[0].id : null;
|
||||
|
||||
const selectedOption = states?.find(
|
||||
(s) => s.id === projectDetails?.default_state ?? defaultState
|
||||
);
|
||||
const currentDefaultState = states.find((s) => s.id === defaultState);
|
||||
|
||||
const initialValues: Partial<IProject> = {
|
||||
close_in: 1,
|
||||
default_state: defaultState,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectMonthModal
|
||||
type="auto-close"
|
||||
initialValues={initialValues}
|
||||
isOpen={monthModal}
|
||||
handleClose={() => setmonthModal(false)}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-brand-base bg-brand-base">
|
||||
<div className="flex items-center justify-between gap-x-8 gap-y-2 ">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h4 className="text-lg font-semibold">Auto-close inactive issues</h4>
|
||||
<p className="text-sm text-brand-secondary">
|
||||
Plane will automatically close the issues that have not been updated for the
|
||||
configured time period.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={projectDetails?.close_in !== 0}
|
||||
onChange={() =>
|
||||
projectDetails?.close_in === 0
|
||||
? handleChange({ close_in: 1, default_state: defaultState })
|
||||
: handleChange({ close_in: 0, default_state: null })
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{projectDetails?.close_in !== 0 && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="w-1/2 text-base font-medium">
|
||||
Auto-close issues that are inactive for
|
||||
</div>
|
||||
<div className="w-1/2 ">
|
||||
<CustomSelect
|
||||
value={projectDetails?.close_in}
|
||||
customButton={
|
||||
<button className="flex w-full items-center justify-between gap-1 rounded-md border border-brand-base shadow-sm duration-300 text-brand-secondary hover:text-brand-base hover:bg-brand-surface-2 focus:outline-none px-3 py-2 text-sm text-left">
|
||||
{`${projectDetails?.close_in} ${
|
||||
projectDetails?.close_in === 1 ? "Month" : "Months"
|
||||
}`}
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ close_in: val });
|
||||
}}
|
||||
input
|
||||
width="w-full"
|
||||
>
|
||||
<>
|
||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||
<CustomSelect.Option key={month.label} value={month.value}>
|
||||
{month.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
Customize Time Range
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="w-1/2 text-base font-medium">Auto-close Status</div>
|
||||
<div className="w-1/2 ">
|
||||
<CustomSearchSelect
|
||||
value={
|
||||
projectDetails?.default_state ? projectDetails?.default_state : defaultState
|
||||
}
|
||||
customButton={
|
||||
<button
|
||||
className={`flex w-full items-center justify-between gap-1 rounded-md border border-brand-base shadow-sm duration-300 text-brand-secondary hover:text-brand-base hover:bg-brand-surface-2 focus:outline-none px-3 py-2 text-sm text-left ${
|
||||
!multipleOptions ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedOption ? (
|
||||
getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)
|
||||
) : currentDefaultState ? (
|
||||
getStateGroupIcon(
|
||||
currentDefaultState.group,
|
||||
"16",
|
||||
"16",
|
||||
currentDefaultState.color
|
||||
)
|
||||
) : (
|
||||
<Squares2X2Icon className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
)}
|
||||
{selectedOption?.name
|
||||
? selectedOption.name
|
||||
: currentDefaultState?.name ?? (
|
||||
<span className="text-custom-text-200">State</span>
|
||||
)}
|
||||
</div>
|
||||
{multipleOptions && (
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
onChange={(val: string) => {
|
||||
handleChange({ default_state: val });
|
||||
}}
|
||||
options={options}
|
||||
disabled={!multipleOptions}
|
||||
dropdownWidth="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
3
apps/app/components/automation/index.ts
Normal file
3
apps/app/components/automation/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./auto-close-automation";
|
||||
export * from "./auto-archive-automation";
|
||||
export * from "./select-month-modal";
|
147
apps/app/components/automation/select-month-modal.tsx
Normal file
147
apps/app/components/automation/select-month-modal.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// types
|
||||
import type { IProject } from "types";
|
||||
|
||||
// types
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
type: "auto-close" | "auto-archive";
|
||||
initialValues: Partial<IProject>;
|
||||
handleClose: () => void;
|
||||
handleChange: (formData: Partial<IProject>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const SelectMonthModal: React.FC<Props> = ({
|
||||
type,
|
||||
initialValues,
|
||||
isOpen,
|
||||
handleClose,
|
||||
handleChange,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
} = useForm<IProject>({
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
reset(initialValues);
|
||||
};
|
||||
|
||||
const onSubmit = (formData: Partial<IProject>) => {
|
||||
if (!workspaceSlug && !projectId) return;
|
||||
handleChange(formData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const inputSection = (name: string) => (
|
||||
<div className="relative flex flex-col gap-1 justify-center w-full">
|
||||
<Input
|
||||
type="number"
|
||||
id={name}
|
||||
name={name}
|
||||
placeholder="Enter Months"
|
||||
autoComplete="off"
|
||||
register={register}
|
||||
width="full"
|
||||
validations={{
|
||||
required: "Select a month between 1 and 12.",
|
||||
min: 1,
|
||||
max: 12,
|
||||
}}
|
||||
className="border-custom-border-200"
|
||||
/>
|
||||
<span className="absolute text-sm text-custom-text-200 top-2.5 right-8">Months</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={onClose}>
|
||||
<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-[#131313] bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-90 px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-custom-text-100"
|
||||
>
|
||||
Customize Time Range
|
||||
</Dialog.Title>
|
||||
<div className="mt-8 flex items-center gap-2">
|
||||
<div className="flex w-full flex-col gap-1 justify-center">
|
||||
{type === "auto-close" ? (
|
||||
<>
|
||||
{inputSection("close_in")}
|
||||
{errors.close_in && (
|
||||
<span className="text-sm px-1 text-red-500">
|
||||
Select a month between 1 and 12.
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{inputSection("archive_in")}
|
||||
{errors.archive_in && (
|
||||
<span className="text-sm px-1 text-red-500">
|
||||
Select a month between 1 and 12.
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Submitting..." : "Submit"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
@ -334,9 +334,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
<h5 className="text-sm group-hover:text-custom-primary break-words line-clamp-3">
|
||||
{issue.name}
|
||||
</h5>
|
||||
<h5 className="text-sm break-words line-clamp-3">{issue.name}</h5>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="relative mt-2.5 flex flex-wrap items-center gap-2 text-xs">
|
||||
|
@ -23,6 +23,7 @@ import { renderShortDateWithYearFormat, timeAgo } from "helpers/date-time.helper
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import RemirrorRichTextEditor from "components/rich-text-editor";
|
||||
import { Icon } from "components/ui";
|
||||
|
||||
const activityDetails: {
|
||||
[key: string]: {
|
||||
@ -105,6 +106,10 @@ const activityDetails: {
|
||||
message: "updated the attachment",
|
||||
icon: <PaperClipIcon className="h-3 w-3 text-custom-text-200 " aria-hidden="true" />,
|
||||
},
|
||||
archived_at: {
|
||||
message: "archived",
|
||||
icon: <Icon iconName="archive" className="text-sm text-custom-text-200" aria-hidden="true" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
@ -144,6 +149,11 @@ export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
action = `${activity.verb} the`;
|
||||
} else if (activity.field === "link") {
|
||||
action = `${activity.verb} the`;
|
||||
} else if (activity.field === "archived_at") {
|
||||
action =
|
||||
activity.new_value && activity.new_value === "restore"
|
||||
? "restored the issue"
|
||||
: "archived the issue";
|
||||
}
|
||||
// for values that are after the action clause
|
||||
let value: any = activity.new_value ? activity.new_value : activity.old_value;
|
||||
@ -205,7 +215,13 @@ export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
<div key={activity.id} className="mt-2">
|
||||
<div className="relative flex items-start space-x-3">
|
||||
<div className="relative px-1">
|
||||
{activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
|
||||
{activity.field ? (
|
||||
activity.new_value === "restore" ? (
|
||||
<Icon iconName="history" className="text-sm text-custom-text-200" />
|
||||
) : (
|
||||
activityDetails[activity.field as keyof typeof activityDetails]?.icon
|
||||
)
|
||||
) : activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
|
||||
<img
|
||||
src={activity.actor_detail.avatar}
|
||||
alt={activity.actor_detail.first_name}
|
||||
@ -296,14 +312,23 @@ export const Feeds: React.FC<any> = ({ activities }) => (
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 py-3">
|
||||
<div className="text-xs text-custom-text-200">
|
||||
<span className="text-gray font-medium">
|
||||
{activity.actor_detail.first_name}
|
||||
{activity.actor_detail.is_bot
|
||||
? " Bot"
|
||||
: " " + activity.actor_detail.last_name}
|
||||
</span>
|
||||
{activity.field === "archived_at" && activity.new_value !== "restore" ? (
|
||||
<span className="text-gray font-medium">Plane</span>
|
||||
) : (
|
||||
<span className="text-gray font-medium">
|
||||
{activity.actor_detail.first_name}
|
||||
{activity.actor_detail.is_bot
|
||||
? " Bot"
|
||||
: " " + activity.actor_detail.last_name}
|
||||
</span>
|
||||
)}
|
||||
<span> {action} </span>
|
||||
<span className="text-xs font-medium text-custom-text-100"> {value} </span>
|
||||
{activity.field !== "archived_at" && (
|
||||
<span className="text-xs font-medium text-custom-text-100">
|
||||
{" "}
|
||||
{value}{" "}
|
||||
</span>
|
||||
)}
|
||||
<span className="whitespace-nowrap">{timeAgo(activity.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -23,13 +23,37 @@ import {
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
|
||||
// types
|
||||
import { Properties } from "types";
|
||||
import { Properties, TIssueViewOptions } from "types";
|
||||
// constants
|
||||
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
|
||||
|
||||
const issueViewOptions: { type: TIssueViewOptions; icon: any }[] = [
|
||||
{
|
||||
type: "list",
|
||||
icon: <ListBulletIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
type: "kanban",
|
||||
icon: <Squares2X2Icon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
type: "calendar",
|
||||
icon: <CalendarDaysIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
type: "spreadsheet",
|
||||
icon: <Icon iconName="table_chart" />,
|
||||
},
|
||||
{
|
||||
type: "gantt_chart",
|
||||
icon: <Icon iconName="waterfall_chart" className="rotate-90" />,
|
||||
},
|
||||
];
|
||||
|
||||
export const IssuesFilterView: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, viewId } = router.query;
|
||||
const isArchivedIssues = router.pathname.includes("archived-issues");
|
||||
|
||||
const {
|
||||
issueView,
|
||||
@ -55,55 +79,24 @@ export const IssuesFilterView: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-sidebar-background-80 ${
|
||||
issueView === "list" ? "bg-custom-sidebar-background-80" : ""
|
||||
}`}
|
||||
onClick={() => setIssueView("list")}
|
||||
>
|
||||
<ListBulletIcon className="h-4 w-4 text-custom-sidebar-text-200" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-sidebar-background-80 ${
|
||||
issueView === "kanban" ? "bg-custom-sidebar-background-80" : ""
|
||||
}`}
|
||||
onClick={() => setIssueView("kanban")}
|
||||
>
|
||||
<Squares2X2Icon className="h-4 w-4 text-custom-sidebar-text-200" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-sidebar-background-80 ${
|
||||
issueView === "calendar" ? "bg-custom-sidebar-background-80" : ""
|
||||
}`}
|
||||
onClick={() => setIssueView("calendar")}
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4 text-custom-sidebar-text-200" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-sidebar-background-80 ${
|
||||
issueView === "spreadsheet" ? "bg-custom-sidebar-background-80" : ""
|
||||
}`}
|
||||
onClick={() => setIssueView("spreadsheet")}
|
||||
>
|
||||
<Icon iconName="table_chart" className="text-custom-sidebar-text-200" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded outline-none duration-300 hover:bg-custom-sidebar-background-80 ${
|
||||
issueView === "gantt_chart" ? "bg-custom-sidebar-background-80" : ""
|
||||
}`}
|
||||
onClick={() => setIssueView("gantt_chart")}
|
||||
>
|
||||
<span className="material-symbols-rounded text-custom-sidebar-text-200 text-[18px] rotate-90">
|
||||
waterfall_chart
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{!isArchivedIssues && (
|
||||
<div className="flex items-center gap-x-1">
|
||||
{issueViewOptions.map((option) => (
|
||||
<button
|
||||
key={option.type}
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
|
||||
issueView === option.type
|
||||
? "bg-custom-sidebar-background-80"
|
||||
: "text-custom-sidebar-text-200"
|
||||
}`}
|
||||
onClick={() => setIssueView(option.type)}
|
||||
>
|
||||
{option.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<SelectFilters
|
||||
filters={filters}
|
||||
onSelect={(option) => {
|
||||
@ -146,7 +139,7 @@ export const IssuesFilterView: React.FC = () => {
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center gap-2 rounded-md border border-custom-sidebar-border-100 bg-transparent px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none ${
|
||||
className={`group flex items-center gap-2 rounded-md border border-custom-sidebar-border-100 bg-transparent px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
|
||||
open
|
||||
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
|
||||
: "text-custom-sidebar-text-200"
|
||||
|
@ -29,19 +29,13 @@ import {
|
||||
} from "components/core";
|
||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||
import { CreateUpdateViewModal } from "components/views";
|
||||
import { CycleIssuesGanttChartView, TransferIssues, TransferIssuesModal } from "components/cycles";
|
||||
import { IssueGanttChartView } from "components/issues/gantt-chart";
|
||||
import { TransferIssues, TransferIssuesModal } from "components/cycles";
|
||||
// ui
|
||||
import { EmptySpace, EmptySpaceItem, EmptyState, PrimaryButton, Spinner } from "components/ui";
|
||||
import { EmptyState, PrimaryButton, Spinner } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ListBulletIcon,
|
||||
PlusIcon,
|
||||
RectangleStackIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
// images
|
||||
import emptyIssue from "public/empty-state/empty-issue.svg";
|
||||
import emptyIssue from "public/empty-state/issue.svg";
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
import { orderArrayBy } from "helpers/array.helper";
|
||||
@ -56,7 +50,6 @@ import {
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
STATES_LIST,
|
||||
} from "constants/fetch-keys";
|
||||
import { ModuleIssuesGanttChartView } from "components/modules";
|
||||
|
||||
type Props = {
|
||||
type?: "issue" | "cycle" | "module";
|
||||
@ -107,7 +100,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
groupByProperty: selectedGroup,
|
||||
orderBy,
|
||||
filters,
|
||||
isNotEmpty,
|
||||
isEmpty,
|
||||
setFilters,
|
||||
params,
|
||||
} = useIssuesView();
|
||||
@ -517,7 +510,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
{groupedByIssues ? (
|
||||
isNotEmpty ? (
|
||||
!isEmpty || issueView === "kanban" || issueView === "calendar" ? (
|
||||
<>
|
||||
{isCompleted && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />}
|
||||
{issueView === "list" ? (
|
||||
@ -584,46 +577,20 @@ export const IssuesView: React.FC<Props> = ({
|
||||
issueView === "gantt_chart" && <GanttChartView />
|
||||
)}
|
||||
</>
|
||||
) : type === "issue" ? (
|
||||
<EmptyState
|
||||
type="issue"
|
||||
title="Create New Issue"
|
||||
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
|
||||
imgURL={emptyIssue}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<EmptySpace
|
||||
title="You don't have any issue yet."
|
||||
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
|
||||
Icon={RectangleStackIcon}
|
||||
>
|
||||
<EmptySpaceItem
|
||||
title="Create a new issue"
|
||||
description={
|
||||
<span>
|
||||
Use <pre className="inline rounded bg-custom-background-80 px-2 py-1">C</pre>{" "}
|
||||
shortcut to create a new issue
|
||||
</span>
|
||||
}
|
||||
Icon={PlusIcon}
|
||||
action={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "c",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
/>
|
||||
{openIssuesListModal && (
|
||||
<EmptySpaceItem
|
||||
title="Add an existing issue"
|
||||
description="Open list"
|
||||
Icon={ListBulletIcon}
|
||||
action={openIssuesListModal}
|
||||
/>
|
||||
)}
|
||||
</EmptySpace>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="Project issues will appear here"
|
||||
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
|
||||
image={emptyIssue}
|
||||
buttonText="New Issue"
|
||||
buttonIcon={<PlusIcon className="h-4 w-4" />}
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "c",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
|
@ -84,6 +84,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
const isArchivedIssues = router.pathname.includes("archived-issues");
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@ -181,7 +182,11 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||
const singleIssuePath = isArchivedIssues
|
||||
? `/${workspaceSlug}/projects/${projectId}/archived-issues/${issue.id}`
|
||||
: `/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`;
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted || isArchivedIssues;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -207,11 +212,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
|
||||
Copy issue link
|
||||
</ContextMenu.Item>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<a href={singleIssuePath} target="_blank" rel="noreferrer noopener">
|
||||
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
|
||||
Open issue in new tab
|
||||
</ContextMenu.Item>
|
||||
@ -225,7 +226,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
setContextMenuPosition({ x: e.pageX, y: e.pageY });
|
||||
}}
|
||||
>
|
||||
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
|
||||
<Link href={singleIssuePath}>
|
||||
<div className="flex-grow cursor-pointer">
|
||||
<a className="group relative flex items-center gap-2">
|
||||
{properties.key && (
|
||||
@ -247,7 +248,11 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex w-full flex-shrink flex-wrap items-center gap-2 text-xs sm:w-auto">
|
||||
<div
|
||||
className={`flex w-full flex-shrink flex-wrap items-center gap-2 text-xs sm:w-auto ${
|
||||
isArchivedIssues ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
{properties.priority && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
|
@ -67,6 +67,7 @@ export const SingleList: React.FC<Props> = ({
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const isArchivedIssues = router.pathname.includes("archived-issues");
|
||||
|
||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||
|
||||
@ -159,7 +160,9 @@ export const SingleList: React.FC<Props> = ({
|
||||
</span>
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
{type === "issue" ? (
|
||||
{isArchivedIssues ? (
|
||||
""
|
||||
) : type === "issue" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-custom-text-200 hover:bg-custom-background-80"
|
||||
|
@ -348,12 +348,12 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
{properties.created_on && (
|
||||
<div className="flex items-center text-xs cursor-default text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
|
||||
<div className="flex items-center text-xs cursor-default text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-100">
|
||||
{renderLongDetailDateFormat(issue.created_at)}
|
||||
</div>
|
||||
)}
|
||||
{properties.updated_on && (
|
||||
<div className="flex items-center text-xs cursor-default text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
|
||||
<div className="flex items-center text-xs cursor-default text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-100">
|
||||
{renderLongDetailDateFormat(issue.updated_at)}
|
||||
</div>
|
||||
)}
|
||||
|
@ -19,8 +19,10 @@ import {
|
||||
} from "components/cycles";
|
||||
// ui
|
||||
import { EmptyState, Loader } from "components/ui";
|
||||
// icons
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// images
|
||||
import emptyCycle from "public/empty-state/empty-cycle.svg";
|
||||
import emptyCycle from "public/empty-state/cycle.svg";
|
||||
// helpers
|
||||
import { getDateRangeStatus } from "helpers/date-time.helper";
|
||||
// types
|
||||
@ -205,10 +207,17 @@ export const CyclesView: React.FC<Props> = ({ cycles, viewType }) => {
|
||||
)
|
||||
) : (
|
||||
<EmptyState
|
||||
type="cycle"
|
||||
title="Create New Cycle"
|
||||
description="Sprint more effectively with Cycles by confining your project to a fixed amount of time. Create new cycle now."
|
||||
imgURL={emptyCycle}
|
||||
title="Plan your project with cycles"
|
||||
description="Cycle is a custom time period in which a team works to complete items on their backlog."
|
||||
image={emptyCycle}
|
||||
buttonText="New Cycle"
|
||||
buttonIcon={<PlusIcon className="h-4 w-4" />}
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "q",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : viewType === "list" ? (
|
||||
|
@ -279,11 +279,9 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
|
||||
e.preventDefault();
|
||||
handleEditCycle();
|
||||
}}
|
||||
className="flex cursor-pointer items-center rounded p-1 text-custom-text-200 duration-300 hover:bg-custom-background-90"
|
||||
className="cursor-pointer rounded p-1 text-custom-text-200 duration-300 hover:bg-custom-background-80"
|
||||
>
|
||||
<span>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
@ -157,7 +156,7 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
|
||||
<a className="w-full">
|
||||
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="flex items-start gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<ContrastIcon
|
||||
className="mt-1 h-5 w-5"
|
||||
color={`${
|
||||
@ -179,15 +178,15 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
|
||||
position="top-left"
|
||||
>
|
||||
<h3 className="break-words w-full text-base font-semibold">
|
||||
{truncateText(cycle.name, 70)}
|
||||
{truncateText(cycle.name, 60)}
|
||||
</h3>
|
||||
</Tooltip>
|
||||
<p className="mt-2 text-custom-text-200 break-words w-full">
|
||||
{cycle.description}
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
<span className="flex items-center gap-4 capitalize">
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-4">
|
||||
<span
|
||||
className={`rounded-full px-1.5 py-0.5
|
||||
${
|
||||
@ -203,14 +202,14 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
|
||||
}`}
|
||||
>
|
||||
{cycleStatus === "current" ? (
|
||||
<span className="flex gap-1">
|
||||
<span className="flex gap-1 whitespace-nowrap">
|
||||
<PersonRunningIcon className="h-4 w-4" />
|
||||
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
|
||||
{findHowManyDaysLeft(cycle.end_date ?? new Date())} days left
|
||||
</span>
|
||||
) : cycleStatus === "upcoming" ? (
|
||||
<span className="flex gap-1">
|
||||
<AlarmClockIcon className="h-4 w-4" />
|
||||
{findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
|
||||
{findHowManyDaysLeft(cycle.start_date ?? new Date())} days left
|
||||
</span>
|
||||
) : cycleStatus === "completed" ? (
|
||||
<span className="flex items-center gap-1">
|
||||
@ -236,12 +235,12 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
|
||||
|
||||
{cycleStatus !== "draft" && (
|
||||
<div className="flex items-center justify-start gap-2 text-custom-text-200">
|
||||
<div className="flex items-start gap-1 ">
|
||||
<div className="flex items-start gap-1 whitespace-nowrap">
|
||||
<CalendarDaysIcon className="h-4 w-4" />
|
||||
<span>{renderShortDateWithYearFormat(startDate)}</span>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
<div className="flex items-start gap-1 ">
|
||||
<div className="flex items-start gap-1 whitespace-nowrap">
|
||||
<TargetIcon className="h-4 w-4" />
|
||||
<span>{renderShortDateWithYearFormat(endDate)}</span>
|
||||
</div>
|
||||
@ -287,7 +286,7 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
|
||||
}`}
|
||||
>
|
||||
{cycleStatus === "current" ? (
|
||||
<span className="flex gap-1">
|
||||
<span className="flex gap-1 whitespace-nowrap">
|
||||
{cycle.total_issues > 0 ? (
|
||||
<>
|
||||
<RadialProgressBar
|
||||
@ -380,7 +379,7 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
@ -72,7 +72,7 @@ export const SingleEstimate: React.FC<Props> = ({
|
||||
<h6 className="flex w-[40vw] items-center gap-2 truncate text-sm font-medium">
|
||||
{estimate.name}
|
||||
{projectDetails?.estimate && projectDetails?.estimate === estimate.id && (
|
||||
<span className="rounded bg-green-500/20 px-2 py-0.5 text-xs capitalize text-green-500">
|
||||
<span className="rounded bg-green-500/20 px-2 py-0.5 text-xs text-green-500">
|
||||
In use
|
||||
</span>
|
||||
)}
|
||||
@ -83,7 +83,10 @@ export const SingleEstimate: React.FC<Props> = ({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{projectDetails?.estimate !== estimate.id && estimate.points.length > 0 && (
|
||||
<SecondaryButton onClick={handleUseEstimate} className="py-1">
|
||||
<SecondaryButton
|
||||
onClick={handleUseEstimate}
|
||||
className="!py-1 text-custom-text-200 hover:text-custom-text-100"
|
||||
>
|
||||
Use
|
||||
</SecondaryButton>
|
||||
)}
|
||||
|
@ -41,7 +41,7 @@ export const FiltersDropdown: React.FC = () => {
|
||||
...PRIORITIES.map((priority) => ({
|
||||
id: priority === null ? "null" : priority,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 capitalize">
|
||||
{getPriorityIcon(priority)} {priority ?? "None"}
|
||||
</div>
|
||||
),
|
||||
|
@ -40,8 +40,8 @@ export const InboxIssueCard: React.FC<Props> = (props) => {
|
||||
<a>
|
||||
<div
|
||||
id={issue.id}
|
||||
className={`relative min-h-[5rem] cursor-pointer select-none space-y-3 py-2 px-4 border-b border-custom-border-100 hover:bg-custom-primary hover:bg-opacity-10 ${
|
||||
active ? "bg-custom-primary bg-opacity-5" : " "
|
||||
className={`relative min-h-[5rem] cursor-pointer select-none space-y-3 py-2 px-4 border-b border-custom-border-100 hover:bg-custom-primary/5 ${
|
||||
active ? "bg-custom-primary/5" : " "
|
||||
} ${issue.issue_inbox[0].status !== -2 ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
|
@ -137,7 +137,7 @@ export const JiraGetImportDetail: React.FC = () => {
|
||||
label={
|
||||
<span>
|
||||
{value && value !== "" ? (
|
||||
projects.find((p) => p.id === value)?.name
|
||||
projects?.find((p) => p.id === value)?.name
|
||||
) : (
|
||||
<span className="text-custom-text-200">Select a project</span>
|
||||
)}
|
||||
@ -145,7 +145,7 @@ export const JiraGetImportDetail: React.FC = () => {
|
||||
}
|
||||
verticalPosition="top"
|
||||
>
|
||||
{projects.length > 0 ? (
|
||||
{projects && projects.length > 0 ? (
|
||||
projects.map((project) => (
|
||||
<CustomSelect.Option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
|
@ -11,7 +11,7 @@ import useEstimateOption from "hooks/use-estimate-option";
|
||||
// components
|
||||
import { CommentCard } from "components/issues/comment";
|
||||
// ui
|
||||
import { Loader } from "components/ui";
|
||||
import { Icon, Loader } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
@ -110,6 +110,10 @@ const activityDetails: {
|
||||
message: "updated the attachment",
|
||||
icon: <PaperClipIcon className="h-3 w-3 text-custom-text-200" aria-hidden="true" />,
|
||||
},
|
||||
archived_at: {
|
||||
message: "archived",
|
||||
icon: <Icon iconName="archive" className="text-sm text-custom-text-200" aria-hidden="true" />,
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@ -253,6 +257,11 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
|
||||
activityItem.new_value && activityItem.new_value !== ""
|
||||
? "set the module to"
|
||||
: "removed the module";
|
||||
} else if (activityItem.field === "archived_at") {
|
||||
action =
|
||||
activityItem.new_value && activityItem.new_value === "restore"
|
||||
? "restored the issue"
|
||||
: "archived the issue";
|
||||
}
|
||||
// for values that are after the action clause
|
||||
let value: any = activityItem.new_value ? activityItem.new_value : activityItem.old_value;
|
||||
@ -345,8 +354,16 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
|
||||
<div className="mt-1.5">
|
||||
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 ring-white">
|
||||
{activityItem.field ? (
|
||||
activityDetails[activityItem.field as keyof typeof activityDetails]
|
||||
?.icon
|
||||
activityItem.new_value === "restore" ? (
|
||||
<Icon
|
||||
iconName="history"
|
||||
className="text-sm text-custom-text-200"
|
||||
/>
|
||||
) : (
|
||||
activityDetails[
|
||||
activityItem.field as keyof typeof activityDetails
|
||||
]?.icon
|
||||
)
|
||||
) : activityItem.actor_detail.avatar &&
|
||||
activityItem.actor_detail.avatar !== "" ? (
|
||||
<img
|
||||
@ -369,17 +386,24 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 py-3">
|
||||
<div className="text-xs text-custom-text-200">
|
||||
<span className="text-gray font-medium">
|
||||
{activityItem.actor_detail.first_name}
|
||||
{activityItem.actor_detail.is_bot
|
||||
? " Bot"
|
||||
: " " + activityItem.actor_detail.last_name}
|
||||
</span>
|
||||
{activityItem.field === "archived_at" &&
|
||||
activityItem.new_value !== "restore" ? (
|
||||
<span className="text-gray font-medium">Plane</span>
|
||||
) : (
|
||||
<span className="text-gray font-medium">
|
||||
{activityItem.actor_detail.first_name}
|
||||
{activityItem.actor_detail.is_bot
|
||||
? " Bot"
|
||||
: " " + activityItem.actor_detail.last_name}
|
||||
</span>
|
||||
)}
|
||||
<span> {action} </span>
|
||||
<span className="text-xs font-medium text-custom-text-100">
|
||||
{" "}
|
||||
{value}{" "}
|
||||
</span>
|
||||
{activityItem.field !== "archived_at" && (
|
||||
<span className="text-xs font-medium text-custom-text-100">
|
||||
{" "}
|
||||
{value}{" "}
|
||||
</span>
|
||||
)}
|
||||
<span className="whitespace-nowrap">
|
||||
{timeAgo(activityItem.created_at)}
|
||||
</span>
|
||||
|
@ -17,7 +17,11 @@ import { IIssueAttachment } from "types";
|
||||
|
||||
const maxFileSize = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
export const IssueAttachmentUpload = () => {
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueAttachmentUpload: React.FC<Props> = ({ disabled = false }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
@ -74,7 +78,7 @@ export const IssueAttachmentUpload = () => {
|
||||
onDrop,
|
||||
maxSize: maxFileSize,
|
||||
multiple: false,
|
||||
disabled: isLoading,
|
||||
disabled: isLoading || disabled,
|
||||
});
|
||||
|
||||
const fileError =
|
||||
@ -85,9 +89,9 @@ export const IssueAttachmentUpload = () => {
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`flex items-center justify-center h-[60px] cursor-pointer border-2 border-dashed text-custom-primary bg-custom-primary/5 text-xs rounded-md px-4 ${
|
||||
className={`flex items-center justify-center h-[60px] border-2 border-dashed text-custom-primary bg-custom-primary/5 text-xs rounded-md px-4 ${
|
||||
isDragActive ? "bg-custom-primary/10 border-custom-primary" : "border-custom-border-100"
|
||||
} ${isDragReject ? "bg-red-100" : ""}`}
|
||||
} ${isDragReject ? "bg-red-100" : ""} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<span className="flex items-center gap-2">
|
||||
|
@ -43,9 +43,10 @@ const defaultValues: Partial<IIssueComment> = {
|
||||
type Props = {
|
||||
issueId: string;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const AddComment: React.FC<Props> = ({ issueId, user }) => {
|
||||
export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false }) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
@ -111,7 +112,7 @@ export const AddComment: React.FC<Props> = ({ issueId, user }) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<SecondaryButton type="submit" disabled={isSubmitting} className="mt-2">
|
||||
<SecondaryButton type="submit" disabled={isSubmitting || disabled} className="mt-2">
|
||||
{isSubmitting ? "Adding..." : "Comment"}
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
|
@ -23,6 +23,7 @@ import type { IIssue, ICurrentUserResponse, ISubIssueResponse } from "types";
|
||||
import {
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
SUB_ISSUES,
|
||||
VIEW_ISSUES,
|
||||
@ -40,6 +41,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
const isArchivedIssues = router.pathname.includes("archived-issues");
|
||||
|
||||
const { issueView, params } = useIssuesView();
|
||||
const { params: calendarParams } = useCalendarIssuesView();
|
||||
@ -119,6 +121,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
|
||||
type: "success",
|
||||
message: "Issue deleted successfully",
|
||||
});
|
||||
router.back();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
@ -126,6 +129,31 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchivedIssueDeletion = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
if (!workspaceSlug || !projectId || !data) return;
|
||||
|
||||
await issueServices
|
||||
.deleteArchivedIssue(workspaceSlug as string, projectId as string, data.id)
|
||||
.then(() => {
|
||||
mutate(PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
|
||||
handleClose();
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Issue deleted successfully",
|
||||
});
|
||||
router.back();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
setIsDeleteLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueDelete = () =>
|
||||
isArchivedIssues ? handleArchivedIssueDeletion() : handleDeletion();
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||
@ -177,7 +205,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
|
||||
</span>
|
||||
<div className="flex justify-end gap-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<DangerButton onClick={handleDeletion} loading={isDeleteLoading}>
|
||||
<DangerButton onClick={handleIssueDelete} loading={isDeleteLoading}>
|
||||
{isDeleteLoading ? "Deleting..." : "Delete Issue"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
|
@ -28,11 +28,16 @@ import { SUB_ISSUES } from "constants/fetch-keys";
|
||||
type Props = {
|
||||
issueDetails: IIssue;
|
||||
submitChanges: (formData: Partial<IIssue>) => Promise<void>;
|
||||
uneditable?: boolean;
|
||||
};
|
||||
|
||||
export const IssueMainContent: React.FC<Props> = ({ issueDetails, submitChanges }) => {
|
||||
export const IssueMainContent: React.FC<Props> = ({
|
||||
issueDetails,
|
||||
submitChanges,
|
||||
uneditable = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
const { workspaceSlug, projectId, issueId, archivedIssueId } = router.query;
|
||||
|
||||
const { user } = useUserAuth();
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
@ -95,23 +100,30 @@ export const IssueMainContent: React.FC<Props> = ({ issueDetails, submitChanges
|
||||
<IssueDescriptionForm
|
||||
issue={issueDetails}
|
||||
handleFormSubmit={submitChanges}
|
||||
isAllowed={memberRole.isMember || memberRole.isOwner}
|
||||
isAllowed={memberRole.isMember || memberRole.isOwner || !uneditable}
|
||||
/>
|
||||
<div className="mt-2 space-y-2">
|
||||
<SubIssuesList parentIssue={issueDetails} user={user} />
|
||||
<SubIssuesList parentIssue={issueDetails} user={user} disabled={uneditable} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
<h3 className="text-lg">Attachments</h3>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<IssueAttachmentUpload />
|
||||
<IssueAttachmentUpload disabled={uneditable} />
|
||||
<IssueAttachments />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-5 pt-3">
|
||||
<h3 className="text-lg text-custom-text-100">Comments/Activity</h3>
|
||||
<IssueActivitySection issueId={issueId as string} user={user} />
|
||||
<AddComment issueId={issueId as string} user={user} />
|
||||
<IssueActivitySection
|
||||
issueId={(archivedIssueId as string) ?? (issueId as string)}
|
||||
user={user}
|
||||
/>
|
||||
<AddComment
|
||||
issueId={(archivedIssueId as string) ?? (issueId as string)}
|
||||
user={user}
|
||||
disabled={uneditable}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -7,7 +7,6 @@ import useSWR, { mutate } from "swr";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
import modulesService from "services/modules.service";
|
||||
import issuesService from "services/issues.service";
|
||||
import inboxServices from "services/inbox.service";
|
||||
@ -18,6 +17,7 @@ import useCalendarIssuesView from "hooks/use-calendar-issues-view";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
||||
import useProjects from "hooks/use-projects";
|
||||
// components
|
||||
import { IssueForm } from "components/issues";
|
||||
// types
|
||||
@ -27,7 +27,6 @@ import {
|
||||
PROJECT_ISSUES_DETAILS,
|
||||
PROJECT_ISSUES_LIST,
|
||||
USER_ISSUE,
|
||||
PROJECTS_LIST,
|
||||
SUB_ISSUES,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
@ -83,6 +82,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
const { params: spreadsheetParams } = useSpreadsheetIssuesView();
|
||||
|
||||
const { user } = useUser();
|
||||
const { projects } = useProjects();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
|
||||
@ -102,11 +103,6 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: projects } = useSWR(
|
||||
workspaceSlug ? PROJECTS_LIST(workspaceSlug as string) : null,
|
||||
workspaceSlug ? () => projectService.getProjects(workspaceSlug as string) : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (projects && projects.length > 0)
|
||||
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
|
||||
@ -323,7 +319,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={() => {}}>
|
||||
<Dialog as="div" className="relative z-20" onClose={() => handleClose()}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
@ -1,15 +1,9 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// hooks
|
||||
import useProjects from "hooks/use-projects";
|
||||
// ui
|
||||
import { CustomSelect } from "components/ui";
|
||||
// icons
|
||||
import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
// fetch-keys
|
||||
import { PROJECTS_LIST } from "constants/fetch-keys";
|
||||
|
||||
export interface IssueProjectSelectProps {
|
||||
value: string;
|
||||
@ -22,14 +16,7 @@ export const IssueProjectSelect: React.FC<IssueProjectSelectProps> = ({
|
||||
onChange,
|
||||
setActiveProject,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
// Fetching Projects List
|
||||
const { data: projects } = useSWR(
|
||||
workspaceSlug ? PROJECTS_LIST(workspaceSlug as string) : null,
|
||||
() => (workspaceSlug ? projectService.getProjects(workspaceSlug as string) : null)
|
||||
);
|
||||
const { projects } = useProjects();
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
|
@ -20,9 +20,15 @@ type Props = {
|
||||
value: string[];
|
||||
onChange: (val: string[]) => void;
|
||||
userAuth: UserAuth;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarAssigneeSelect: React.FC<Props> = ({ value, onChange, userAuth }) => {
|
||||
export const SidebarAssigneeSelect: React.FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
userAuth,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
@ -53,7 +59,7 @@ export const SidebarAssigneeSelect: React.FC<Props> = ({ value, onChange, userAu
|
||||
),
|
||||
}));
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
|
@ -21,6 +21,7 @@ type Props = {
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
watch: UseFormWatch<IIssue>;
|
||||
userAuth: UserAuth;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarBlockedSelect: React.FC<Props> = ({
|
||||
@ -28,6 +29,7 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
|
||||
submitChanges,
|
||||
watch,
|
||||
userAuth,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
|
||||
|
||||
@ -69,7 +71,7 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -21,6 +21,7 @@ type Props = {
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
watch: UseFormWatch<IIssue>;
|
||||
userAuth: UserAuth;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarBlockerSelect: React.FC<Props> = ({
|
||||
@ -28,6 +29,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
|
||||
submitChanges,
|
||||
watch,
|
||||
userAuth,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
|
||||
|
||||
@ -69,7 +71,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -22,12 +22,14 @@ 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();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
@ -61,7 +63,7 @@ export const SidebarCycleSelect: React.FC<Props> = ({
|
||||
|
||||
const issueCycle = issueDetail?.issue_cycle;
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
|
@ -13,10 +13,16 @@ type Props = {
|
||||
value: number | null;
|
||||
onChange: (val: number | null) => void;
|
||||
userAuth: UserAuth;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarEstimateSelect: React.FC<Props> = ({ value, onChange, userAuth }) => {
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
export const SidebarEstimateSelect: React.FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
userAuth,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
const { isEstimateActive, estimatePoints } = useEstimateOption();
|
||||
|
||||
@ -46,7 +52,7 @@ export const SidebarEstimateSelect: React.FC<Props> = ({ value, onChange, userAu
|
||||
onChange={onChange}
|
||||
position="right"
|
||||
width="w-full"
|
||||
disabled={isNotAllowed}
|
||||
disabled={isNotAllowed || disabled}
|
||||
>
|
||||
<CustomSelect.Option value={null}>
|
||||
<>
|
||||
|
@ -21,12 +21,14 @@ 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();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
@ -55,7 +57,7 @@ export const SidebarModuleSelect: React.FC<Props> = ({
|
||||
|
||||
const issueModule = issueDetail?.issue_module;
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
|
@ -23,6 +23,7 @@ type Props = {
|
||||
customDisplay: JSX.Element;
|
||||
watch: UseFormWatch<IIssue>;
|
||||
userAuth: UserAuth;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarParentSelect: React.FC<Props> = ({
|
||||
@ -31,6 +32,7 @@ export const SidebarParentSelect: React.FC<Props> = ({
|
||||
customDisplay,
|
||||
watch,
|
||||
userAuth,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
|
||||
|
||||
@ -46,7 +48,7 @@ export const SidebarParentSelect: React.FC<Props> = ({
|
||||
: null
|
||||
);
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
|
@ -14,10 +14,16 @@ type Props = {
|
||||
value: string | null;
|
||||
onChange: (val: string) => void;
|
||||
userAuth: UserAuth;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarPrioritySelect: React.FC<Props> = ({ value, onChange, userAuth }) => {
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
export const SidebarPrioritySelect: React.FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
userAuth,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
|
@ -23,9 +23,15 @@ type Props = {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
userAuth: UserAuth;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, userAuth }) => {
|
||||
export const SidebarStateSelect: React.FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
userAuth,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, inboxIssueId } = router.query;
|
||||
|
||||
@ -39,7 +45,7 @@ export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, userAuth
|
||||
|
||||
const selectedState = states?.find((s) => s.id === value);
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
|
@ -74,6 +74,7 @@ type Props = {
|
||||
| "delete"
|
||||
| "all"
|
||||
)[];
|
||||
uneditable?: boolean;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IIssueLabels> = {
|
||||
@ -87,6 +88,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
issueDetail,
|
||||
watch: watchIssue,
|
||||
fieldsToShow = ["all"],
|
||||
uneditable = false,
|
||||
}) => {
|
||||
const [createLabelForm, setCreateLabelForm] = useState(false);
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
@ -321,7 +323,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y-2 divide-custom-border-100">
|
||||
|
||||
<div className={`divide-y-2 divide-custom-border-100 ${uneditable ? "opacity-60" : ""}`}>
|
||||
{showFirstSection && (
|
||||
<div className="py-1">
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
|
||||
@ -333,6 +336,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
value={value}
|
||||
onChange={(val: string) => submitChanges({ state: val })}
|
||||
userAuth={memberRole}
|
||||
disabled={uneditable}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -346,6 +350,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
value={value}
|
||||
onChange={(val: string[]) => submitChanges({ assignees_list: val })}
|
||||
userAuth={memberRole}
|
||||
disabled={uneditable}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -359,6 +364,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
value={value}
|
||||
onChange={(val: string) => submitChanges({ priority: val })}
|
||||
userAuth={memberRole}
|
||||
disabled={uneditable}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -372,6 +378,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
value={value}
|
||||
onChange={(val: number | null) => submitChanges({ estimate_point: val })}
|
||||
userAuth={memberRole}
|
||||
disabled={uneditable}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -403,6 +410,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
disabled={uneditable}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
|
||||
@ -411,6 +419,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
submitChanges={submitChanges}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
disabled={uneditable}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
|
||||
@ -419,6 +428,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
submitChanges={submitChanges}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
disabled={uneditable}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
|
||||
@ -441,7 +451,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
})
|
||||
}
|
||||
className="bg-custom-background-90"
|
||||
disabled={isNotAllowed}
|
||||
disabled={isNotAllowed || uneditable}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -457,6 +467,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
issueDetail={issueDetail}
|
||||
handleCycleChange={handleCycleChange}
|
||||
userAuth={memberRole}
|
||||
disabled={uneditable}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && (
|
||||
@ -464,13 +475,14 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
issueDetail={issueDetail}
|
||||
handleModuleChange={handleModuleChange}
|
||||
userAuth={memberRole}
|
||||
disabled={uneditable}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && (
|
||||
<div className="space-y-3 py-3">
|
||||
<div className={`space-y-3 py-3 ${uneditable ? "opacity-60" : ""}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex basis-1/2 items-center gap-x-2 text-sm text-custom-text-200">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
@ -517,13 +529,13 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
onChange={(val: any) => submitChanges({ labels_list: val })}
|
||||
className="flex-shrink-0"
|
||||
multiple
|
||||
disabled={isNotAllowed}
|
||||
disabled={isNotAllowed || uneditable}
|
||||
>
|
||||
{({ open }) => (
|
||||
<div className="relative">
|
||||
<Listbox.Button
|
||||
className={`flex ${
|
||||
isNotAllowed
|
||||
isNotAllowed || uneditable
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-custom-background-90"
|
||||
} items-center gap-2 rounded-2xl border border-custom-border-100 px-2 py-0.5 text-xs text-custom-text-200`}
|
||||
@ -628,11 +640,12 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
<button
|
||||
type="button"
|
||||
className={`flex ${
|
||||
isNotAllowed
|
||||
isNotAllowed || uneditable
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-custom-background-90"
|
||||
} items-center gap-1 rounded-2xl border border-custom-border-100 px-2 py-0.5 text-xs text-custom-text-200`}
|
||||
onClick={() => setCreateLabelForm((prevData) => !prevData)}
|
||||
disabled={uneditable}
|
||||
>
|
||||
{createLabelForm ? (
|
||||
<>
|
||||
@ -723,14 +736,17 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
|
||||
<div className="min-h-[116px] py-1 text-xs">
|
||||
<div className={`min-h-[116px] py-1 text-xs ${uneditable ? "opacity-60" : ""}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4>Links</h4>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-90"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-90 ${
|
||||
uneditable ? "cursor-not-allowed" : "cursor-pointer"
|
||||
}`}
|
||||
onClick={() => setLinkModal(true)}
|
||||
disabled={uneditable}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
@ -28,9 +28,10 @@ import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys";
|
||||
type Props = {
|
||||
parentIssue: IIssue;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SubIssuesList: FC<Props> = ({ parentIssue, user }) => {
|
||||
export const SubIssuesList: FC<Props> = ({ parentIssue, user, disabled = false }) => {
|
||||
// states
|
||||
const [createIssueModal, setCreateIssueModal] = useState(false);
|
||||
const [subIssuesListModal, setSubIssuesListModal] = useState(false);
|
||||
@ -180,7 +181,7 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user }) => {
|
||||
|
||||
const completionPercentage = (completedSubIssues / totalSubIssues) * 100;
|
||||
|
||||
const isNotAllowed = memberRole.isGuest || memberRole.isViewer;
|
||||
const isNotAllowed = memberRole.isGuest || memberRole.isViewer || disabled;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -8,6 +8,7 @@ import { findHowManyDaysLeft, renderShortDateWithYearFormat } from "helpers/date
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
@ -29,6 +30,8 @@ export const ViewDueDateSelect: React.FC<Props> = ({
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { issueView } = useIssuesView();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltipHeading="Due Date"
|
||||
@ -71,7 +74,9 @@ export const ViewDueDateSelect: React.FC<Props> = ({
|
||||
user
|
||||
);
|
||||
}}
|
||||
className={issue?.target_date ? "w-[6.5rem]" : "w-[5rem] text-center"}
|
||||
className={`${issue?.target_date ? "w-[6.5rem]" : "w-[5rem] text-center"} ${
|
||||
issueView === "kanban" ? "bg-custom-background-90" : "bg-custom-background-100"
|
||||
}`}
|
||||
noBorder={noBorder}
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
|
@ -71,9 +71,15 @@ export const ViewLabelSelect: React.FC<Props> = ({
|
||||
position={tooltipPosition}
|
||||
tooltipHeading="Labels"
|
||||
tooltipContent={
|
||||
issue.label_details.length > 0
|
||||
? issue.label_details.map((label) => label.name ?? "").join(", ")
|
||||
: "No Label"
|
||||
issue.labels.length > 0
|
||||
? issue.labels
|
||||
.map((labelId) => {
|
||||
const label = issueLabels?.find((l) => l.id === labelId);
|
||||
|
||||
return label?.name ?? "";
|
||||
})
|
||||
.join(", ")
|
||||
: "No label"
|
||||
}
|
||||
>
|
||||
<div
|
||||
@ -81,20 +87,23 @@ export const ViewLabelSelect: React.FC<Props> = ({
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center gap-2 text-custom-text-200`}
|
||||
>
|
||||
{issue.label_details.length > 0 ? (
|
||||
{issue.labels.length > 0 ? (
|
||||
<>
|
||||
{issue.label_details.slice(0, 4).map((label, index) => (
|
||||
<div className={`flex h-4 w-4 rounded-full ${index ? "-ml-3.5" : ""}`}>
|
||||
<span
|
||||
className={`h-4 w-4 flex-shrink-0 rounded-full border group-hover:bg-custom-background-80 border-custom-border-100
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{issue.label_details.length > 4 ? <span>+{issue.label_details.length - 4}</span> : null}
|
||||
{issue.labels.slice(0, 4).map((labelId, index) => {
|
||||
const label = issueLabels?.find((l) => l.id === labelId);
|
||||
|
||||
return (
|
||||
<div className={`flex h-4 w-4 rounded-full ${index ? "-ml-3.5" : ""}`}>
|
||||
<span
|
||||
className={`h-4 w-4 flex-shrink-0 rounded-full border group-hover:bg-custom-background-80 border-custom-border-100`}
|
||||
style={{
|
||||
backgroundColor: label?.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{issue.labels.length > 4 ? <span>+{issue.labels.length - 4}</span> : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
@ -10,7 +10,6 @@ import { CustomSearchSelect, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { getStateGroupIcon } from "components/icons";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
@ -67,7 +66,7 @@ export const ViewStateSelect: React.FC<Props> = ({
|
||||
const stateLabel = (
|
||||
<Tooltip
|
||||
tooltipHeading="State"
|
||||
tooltipContent={addSpaceIfCamelCase(selectedOption?.name ?? "")}
|
||||
tooltipContent={selectedOption?.name ?? ""}
|
||||
position={tooltipPosition}
|
||||
>
|
||||
<div className="flex items-center cursor-pointer w-full gap-2 text-custom-text-200">
|
||||
|
@ -185,12 +185,12 @@ export const SingleModuleCard: React.FC<Props> = ({ module, handleEditModule, us
|
||||
<div className="flex items-start gap-1">
|
||||
<CalendarDaysIcon className="h-4 w-4" />
|
||||
<span>Start:</span>
|
||||
<span>{renderShortDateWithYearFormat(startDate)}</span>
|
||||
<span>{renderShortDateWithYearFormat(startDate, "Not set")}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-1">
|
||||
<TargetIcon className="h-4 w-4" />
|
||||
<span>End:</span>
|
||||
<span>{renderShortDateWithYearFormat(endDate)}</span>
|
||||
<span>{renderShortDateWithYearFormat(endDate, "Not set")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
export * from "./tour";
|
||||
export * from "./invite-members";
|
||||
export * from "./onboarding-card";
|
||||
export * from "./join-workspaces";
|
||||
export * from "./user-details";
|
||||
export * from "./workspace";
|
||||
export * from "./onboarding-logo";
|
||||
|
@ -1,87 +1,215 @@
|
||||
// types
|
||||
import { useForm } from "react-hook-form";
|
||||
import useToast from "hooks/use-toast";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
// services
|
||||
import workspaceService from "services/workspace.service";
|
||||
import { ICurrentUserResponse, IUser } from "types";
|
||||
// ui components
|
||||
import { MultiInput, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
import userService from "services/user.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { CustomSelect, Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { ICurrentUserResponse, IWorkspace, OnboardingSteps } from "types";
|
||||
// fetch-keys
|
||||
import { CURRENT_USER, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { ROLE } from "constants/workspace";
|
||||
|
||||
type Props = {
|
||||
setStep: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
workspace: any;
|
||||
workspace: IWorkspace | undefined;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
stepChange: (steps: Partial<OnboardingSteps>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const InviteMembers: React.FC<Props> = ({ setStep, workspace, user }) => {
|
||||
type EmailRole = {
|
||||
email: string;
|
||||
role: 5 | 10 | 15 | 20;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
emails: EmailRole[];
|
||||
};
|
||||
|
||||
export const InviteMembers: React.FC<Props> = ({ workspace, user, stepChange }) => {
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
setValue,
|
||||
watch,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<IUser>();
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<FormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "emails",
|
||||
});
|
||||
|
||||
const { data: invitations } = useSWR(USER_WORKSPACE_INVITATIONS, () =>
|
||||
workspaceService.userWorkspaceInvitations()
|
||||
);
|
||||
|
||||
const nextStep = async () => {
|
||||
if (!user || !invitations) return;
|
||||
|
||||
const payload: Partial<OnboardingSteps> = {
|
||||
workspace_invite: true,
|
||||
};
|
||||
|
||||
// update onboarding status from this step if no invitations are present
|
||||
if (invitations.length === 0) {
|
||||
payload.workspace_join = true;
|
||||
|
||||
mutate<ICurrentUserResponse>(
|
||||
CURRENT_USER,
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
is_onboarded: true,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
await userService.updateUserOnBoard({ userRole: user.role }, user);
|
||||
}
|
||||
|
||||
await stepChange(payload);
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: FormValues) => {
|
||||
if (!workspace) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
|
||||
const onSubmit = async (formData: IUser) => {
|
||||
await workspaceService
|
||||
.inviteWorkspace(workspace.slug, formData, user)
|
||||
.then(() => {
|
||||
.inviteWorkspace(workspace.slug, payload, user)
|
||||
.then(async () => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Invitations sent!",
|
||||
title: "Success!",
|
||||
message: "Invitations sent successfully.",
|
||||
});
|
||||
setStep(4);
|
||||
|
||||
await nextStep();
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
};
|
||||
|
||||
const checkEmail = watch("emails") && watch("emails").length > 0;
|
||||
const appendField = () => {
|
||||
append({ email: "", role: 15 });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fields.length === 0) {
|
||||
append([
|
||||
{ email: "", role: 15 },
|
||||
{ email: "", role: 15 },
|
||||
{ email: "", role: 15 },
|
||||
]);
|
||||
}
|
||||
}, [fields, append]);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex w-full items-center justify-center"
|
||||
className="w-full space-y-7 sm:space-y-10 overflow-hidden flex flex-col"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.code === "Enter") e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full max-w-xl flex-col gap-12">
|
||||
<div className="flex flex-col gap-6 rounded-[10px] bg-custom-background-100 p-7 shadow-md">
|
||||
<h2 className="text-xl font-medium">Invite co-workers to your team</h2>
|
||||
<div className="flex flex-col items-start justify-center gap-2.5">
|
||||
<span>Email</span>
|
||||
<div className="w-full">
|
||||
<MultiInput
|
||||
name="emails"
|
||||
placeholder="Enter co-workers Email IDs"
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
className="w-full"
|
||||
/>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold">Invite people to collaborate</h2>
|
||||
<div className="md:w-3/5 text-sm h-full max-h-[40vh] flex flex-col overflow-hidden">
|
||||
<div className="grid grid-cols-11 gap-x-4 mb-1 text-sm">
|
||||
<h6 className="col-span-7">Co-workers Email</h6>
|
||||
<h6 className="col-span-4">Role</h6>
|
||||
</div>
|
||||
<div className="space-y-3 sm:space-y-4 mb-3 h-full overflow-y-auto">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="group relative grid grid-cols-11 gap-4">
|
||||
<div className="col-span-7">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`emails.${index}.email`}
|
||||
rules={{
|
||||
required: "Email ID is required",
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: "Invalid Email ID",
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<Input
|
||||
{...field}
|
||||
className="text-xs sm:text-sm"
|
||||
placeholder="Enter their email..."
|
||||
/>
|
||||
{errors.emails?.[index]?.email && (
|
||||
<span className="text-red-500 text-xs">
|
||||
{errors.emails?.[index]?.email?.message}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`emails.${index}.role`}
|
||||
rules={{ required: true }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
label={<span className="text-xs sm:text-sm">{ROLE[value]}</span>}
|
||||
onChange={onChange}
|
||||
width="w-full"
|
||||
input
|
||||
>
|
||||
{Object.entries(ROLE).map(([key, value]) => (
|
||||
<CustomSelect.Option key={key} value={parseInt(key)}>
|
||||
{value}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{fields.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="hidden group-hover:grid self-center place-items-center rounded -ml-3"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<XMarkIcon className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="flex w-1/2 items-center justify-center text-center"
|
||||
disabled={!checkEmail}
|
||||
loading={isSubmitting}
|
||||
size="md"
|
||||
>
|
||||
{isSubmitting ? "Inviting..." : "Continue"}
|
||||
</PrimaryButton>
|
||||
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
className="w-1/2 rounded-lg border-none bg-transparent"
|
||||
size="md"
|
||||
outline
|
||||
onClick={() => setStep(4)}
|
||||
>
|
||||
Skip
|
||||
</SecondaryButton>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 outline-custom-primary-100 bg-transparent text-custom-primary-100 text-xs font-medium py-2 pr-3"
|
||||
onClick={appendField}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add another
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<PrimaryButton type="submit" loading={isSubmitting} size="md">
|
||||
{isSubmitting ? "Sending..." : "Send Invite"}
|
||||
</PrimaryButton>
|
||||
<SecondaryButton size="md" onClick={nextStep} outline>
|
||||
Skip this step
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
155
apps/app/components/onboarding/join-workspaces.tsx
Normal file
155
apps/app/components/onboarding/join-workspaces.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import workspaceService from "services/workspace.service";
|
||||
import userService from "services/user.service";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IUser, IWorkspaceMemberInvitation, OnboardingSteps } from "types";
|
||||
// fetch-keys
|
||||
import { CURRENT_USER, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { ROLE } from "constants/workspace";
|
||||
|
||||
type Props = {
|
||||
stepChange: (steps: Partial<OnboardingSteps>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const JoinWorkspaces: React.FC<Props> = ({ stepChange }) => {
|
||||
const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false);
|
||||
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { data: invitations, mutate: mutateInvitations } = useSWR(USER_WORKSPACE_INVITATIONS, () =>
|
||||
workspaceService.userWorkspaceInvitations()
|
||||
);
|
||||
|
||||
const handleInvitation = (
|
||||
workspace_invitation: IWorkspaceMemberInvitation,
|
||||
action: "accepted" | "withdraw"
|
||||
) => {
|
||||
if (action === "accepted") {
|
||||
setInvitationsRespond((prevData) => [...prevData, workspace_invitation.id]);
|
||||
} else if (action === "withdraw") {
|
||||
setInvitationsRespond((prevData) =>
|
||||
prevData.filter((item: string) => item !== workspace_invitation.id)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// complete onboarding
|
||||
const finishOnboarding = async () => {
|
||||
if (!user) return;
|
||||
|
||||
mutate<ICurrentUserResponse>(
|
||||
CURRENT_USER,
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
is_onboarded: true,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
await userService.updateUserOnBoard({ userRole: user.role }, user);
|
||||
await stepChange({ workspace_join: true });
|
||||
};
|
||||
|
||||
const submitInvitations = async () => {
|
||||
if (invitationsRespond.length <= 0) return;
|
||||
|
||||
setIsJoiningWorkspaces(true);
|
||||
|
||||
await workspaceService
|
||||
.joinWorkspaces({ invitations: invitationsRespond })
|
||||
.then(async () => {
|
||||
await mutateInvitations();
|
||||
await finishOnboarding();
|
||||
|
||||
setIsJoiningWorkspaces(false);
|
||||
})
|
||||
.catch(() => setIsJoiningWorkspaces(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-7 sm:space-y-10">
|
||||
<h5 className="sm:text-lg">We see that someone has invited you to</h5>
|
||||
<h4 className="text-xl sm:text-2xl font-semibold">Join a workspace</h4>
|
||||
<div className="md:w-3/5 space-y-4">
|
||||
{invitations &&
|
||||
invitations.map((invitation) => {
|
||||
const isSelected = invitationsRespond.includes(invitation.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={invitation.id}
|
||||
className={`flex cursor-pointer items-center gap-2 border py-5 px-3.5 rounded ${
|
||||
isSelected
|
||||
? "border-custom-primary-100"
|
||||
: "border-custom-border-100 hover:bg-custom-background-80"
|
||||
}`}
|
||||
onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="grid place-items-center h-9 w-9 rounded">
|
||||
{invitation.workspace.logo && invitation.workspace.logo !== "" ? (
|
||||
<img
|
||||
src={invitation.workspace.logo}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded"
|
||||
alt={invitation.workspace.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid place-items-center h-9 w-9 py-1.5 px-3 rounded bg-gray-700 uppercase text-white">
|
||||
{invitation.workspace.name[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">
|
||||
{truncateText(invitation.workspace.name, 30)}
|
||||
</div>
|
||||
<p className="text-xs text-custom-text-200">{ROLE[invitation.role]}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`flex-shrink-0 ${
|
||||
isSelected ? "text-custom-primary-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<CheckCircleIcon className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
size="md"
|
||||
onClick={submitInvitations}
|
||||
disabled={isJoiningWorkspaces || invitationsRespond.length === 0}
|
||||
>
|
||||
Accept & Join
|
||||
</PrimaryButton>
|
||||
<SecondaryButton size="md" onClick={finishOnboarding} outline>
|
||||
Skip for now
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,29 +0,0 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
interface IOnboardingCard {
|
||||
step: string;
|
||||
title: string;
|
||||
description: React.ReactNode | string;
|
||||
imgURL: string;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
data: IOnboardingCard;
|
||||
gradient?: boolean;
|
||||
};
|
||||
|
||||
export const OnboardingCard: React.FC<Props> = ({ data, gradient = false }) => (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-7 rounded-[10px] px-14 pt-10 text-center ${
|
||||
gradient ? "bg-gradient-to-b from-[#C1DDFF] via-brand-base to-transparent" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="h-44 w-full">
|
||||
<Image src={data.imgURL} height="180" width="450" alt={data.title} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-medium">{data.title}</h3>
|
||||
<p className="text-base text-custom-text-200">{data.description}</p>
|
||||
<span className="text-base text-custom-text-200">{data.step}</span>
|
||||
</div>
|
||||
);
|
@ -1,29 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export const OnboardingLogo: React.FC<Props> = ({
|
||||
width = "378",
|
||||
height = "117",
|
||||
color = "#858E96",
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 378 117"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M101.928 74V15.0505H128.464C134.757 15.0505 139.714 16.7721 143.335 20.2152C146.956 23.599 148.767 28.1998 148.767 34.0176C148.767 39.8354 146.956 44.4659 143.335 47.909C139.714 51.2929 134.757 52.9848 128.464 52.9848H108.606V74H101.928ZM108.606 46.7514H128.019C132.649 46.7514 136.152 45.6235 138.526 43.3676C140.901 41.1117 142.088 37.9951 142.088 34.0176C142.088 30.0995 140.901 27.0125 138.526 24.7567C136.152 22.5008 132.649 21.3729 128.019 21.3729H108.606V46.7514ZM152.411 74V11.6667H159.09V74H152.411ZM185.455 74.7124C181.121 74.7124 177.322 73.7032 174.057 71.6848C170.851 69.607 168.358 66.8168 166.577 63.3143C164.796 59.8117 163.906 55.953 163.906 51.7381C163.906 47.4638 164.796 43.6051 166.577 40.1619C168.358 36.6594 170.851 33.8989 174.057 31.8805C177.322 29.8027 181.121 28.7638 185.455 28.7638C189.136 28.7638 192.282 29.4762 194.894 30.9009C197.566 32.3257 199.732 34.2551 201.395 36.689V29.4762H208.073V74H201.395V66.8762C199.732 69.2508 197.566 71.1505 194.894 72.5752C192.282 74 189.136 74.7124 185.455 74.7124ZM186.346 68.6571C189.67 68.6571 192.46 67.8854 194.716 66.3419C197.031 64.7984 198.783 62.7503 199.97 60.1976C201.157 57.5856 201.751 54.7657 201.751 51.7381C201.751 48.6511 201.157 45.8313 199.97 43.2786C198.783 40.7259 197.031 38.6778 194.716 37.1343C192.46 35.5908 189.67 34.819 186.346 34.819C183.08 34.819 180.261 35.5908 177.886 37.1343C175.511 38.6778 173.701 40.7259 172.454 43.2786C171.207 45.8313 170.584 48.6511 170.584 51.7381C170.584 54.7657 171.207 57.5856 172.454 60.1976C173.701 62.7503 175.511 64.7984 177.886 66.3419C180.261 67.8854 183.08 68.6571 186.346 68.6571ZM215.618 74V29.4762H222.296V36.4219C223.899 34.2848 225.858 32.4741 228.174 30.99C230.489 29.5059 233.457 28.7638 237.078 28.7638C240.165 28.7638 243.045 29.5059 245.716 30.99C248.447 32.4148 250.643 34.5816 252.305 37.4905C254.027 40.34 254.888 43.8722 254.888 48.0871V74H248.209V48.2652C248.209 44.2284 247.052 40.993 244.736 38.559C242.421 36.0657 239.423 34.819 235.743 34.819C233.249 34.819 230.993 35.383 228.975 36.5109C226.957 37.6389 225.324 39.2417 224.077 41.3195C222.89 43.3379 222.296 45.6829 222.296 48.3543V74H215.618ZM281.816 74.7124C277.305 74.7124 273.357 73.7032 269.973 71.6848C266.589 69.607 263.948 66.8168 262.048 63.3143C260.208 59.8117 259.287 55.953 259.287 51.7381C259.287 47.4638 260.178 43.6051 261.959 40.1619C263.74 36.6594 266.292 33.8989 269.617 31.8805C272.941 29.8027 276.859 28.7638 281.371 28.7638C285.942 28.7638 289.86 29.8027 293.125 31.8805C296.45 33.8989 299.003 36.6594 300.784 40.1619C302.565 43.6051 303.455 47.4638 303.455 51.7381V54.4095H266.144C266.5 57.0216 267.331 59.4259 268.637 61.6224C270.003 63.7595 271.813 65.4811 274.069 66.7871C276.325 68.0338 278.937 68.6571 281.905 68.6571C285.052 68.6571 287.694 67.9744 289.831 66.609C291.968 65.1843 293.63 63.3736 294.817 61.1771H302.119C300.576 65.1546 298.112 68.4197 294.728 70.9724C291.404 73.4657 287.1 74.7124 281.816 74.7124ZM266.233 48.1762H296.509C295.916 44.3768 294.313 41.2008 291.701 38.6481C289.089 36.0954 285.645 34.819 281.371 34.819C277.097 34.819 273.654 36.0954 271.042 38.6481C268.489 41.2008 266.886 44.3768 266.233 48.1762Z" />
|
||||
<path d="M81 8H27V36H54V63H81V8Z" fill="#3F76FF" />
|
||||
<rect y="36" width="27" height="27" fill="#3F76FF" />
|
||||
<rect x="27" y="63" width="27" height="27" fill="#3F76FF" />
|
||||
</svg>
|
||||
);
|
2
apps/app/components/onboarding/tour/index.ts
Normal file
2
apps/app/components/onboarding/tour/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./root";
|
||||
export * from "./sidebar";
|
161
apps/app/components/onboarding/tour/root.tsx
Normal file
161
apps/app/components/onboarding/tour/root.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// components
|
||||
import { TourSidebar } from "components/onboarding";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// images
|
||||
import PlaneWhiteLogo from "public/plane-logos/white-horizontal.svg";
|
||||
import IssuesTour from "public/onboarding/issues.svg";
|
||||
import CyclesTour from "public/onboarding/cycles.svg";
|
||||
import ModulesTour from "public/onboarding/modules.svg";
|
||||
import ViewsTour from "public/onboarding/views.svg";
|
||||
import PagesTour from "public/onboarding/pages.svg";
|
||||
|
||||
type Props = {
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
export type TTourSteps = "welcome" | "issues" | "cycles" | "modules" | "views" | "pages";
|
||||
|
||||
const TOUR_STEPS: {
|
||||
key: TTourSteps;
|
||||
title: string;
|
||||
description: string;
|
||||
image: any;
|
||||
prevStep?: TTourSteps;
|
||||
nextStep?: TTourSteps;
|
||||
}[] = [
|
||||
{
|
||||
key: "issues",
|
||||
title: "Plan with issues",
|
||||
description:
|
||||
"The issue is the building block of the Plane. Most concepts in Plane are either associated with issues and their properties.",
|
||||
image: IssuesTour,
|
||||
nextStep: "cycles",
|
||||
},
|
||||
{
|
||||
key: "cycles",
|
||||
title: "Move with cycles",
|
||||
description:
|
||||
"Cycles help you and your team to progress faster, similar to the sprints commonly used in agile development.",
|
||||
image: CyclesTour,
|
||||
prevStep: "issues",
|
||||
nextStep: "modules",
|
||||
},
|
||||
{
|
||||
key: "modules",
|
||||
title: "Break into modules",
|
||||
description:
|
||||
"Modules break your big think into Projects or Features, to help you organize better.",
|
||||
image: ModulesTour,
|
||||
prevStep: "cycles",
|
||||
nextStep: "views",
|
||||
},
|
||||
{
|
||||
key: "views",
|
||||
title: "Views",
|
||||
description:
|
||||
"Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks.",
|
||||
image: ViewsTour,
|
||||
prevStep: "modules",
|
||||
nextStep: "pages",
|
||||
},
|
||||
{
|
||||
key: "pages",
|
||||
title: "Document with pages",
|
||||
description: "Use Pages to quickly jot down issues when you're in a meeting or starting a day.",
|
||||
image: PagesTour,
|
||||
prevStep: "views",
|
||||
},
|
||||
];
|
||||
|
||||
export const TourRoot: React.FC<Props> = ({ onComplete }) => {
|
||||
const [step, setStep] = useState<TTourSteps>("welcome");
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const currentStepIndex = TOUR_STEPS.findIndex((tourStep) => tourStep.key === step);
|
||||
const currentStep = TOUR_STEPS[currentStepIndex];
|
||||
|
||||
return (
|
||||
<>
|
||||
{step === "welcome" ? (
|
||||
<div className="w-4/5 md:w-1/2 lg:w-2/5 h-3/4 bg-custom-background-100 rounded-[10px] overflow-hidden">
|
||||
<div className="h-full overflow-hidden">
|
||||
<div className="h-3/5 bg-custom-primary-100 grid place-items-center">
|
||||
<Image src={PlaneWhiteLogo} alt="Plane White Logo" />
|
||||
</div>
|
||||
<div className="h-2/5 overflow-y-auto p-6">
|
||||
<h3 className="font-medium text-lg">
|
||||
Welcome to Plane, {user?.first_name} {user?.last_name}
|
||||
</h3>
|
||||
<p className="text-custom-text-200 text-sm mt-3">
|
||||
We{"'"}re glad that you decided to try out Plane. You can now manage your projects
|
||||
with ease. Get started by creating a project.
|
||||
</p>
|
||||
<div className="flex items-center gap-6 mt-8">
|
||||
<PrimaryButton onClick={() => setStep("issues")}>Take a Product Tour</PrimaryButton>
|
||||
<button
|
||||
type="button"
|
||||
className="outline-custom-text-100 bg-transparent text-custom-primary-100 text-xs font-medium"
|
||||
onClick={onComplete}
|
||||
>
|
||||
No thanks, I will explore it myself
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-4/5 md:w-1/2 lg:w-3/5 h-3/5 sm:h-3/4 bg-custom-background-100 rounded-[10px] grid grid-cols-10 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="fixed top-[19%] sm:top-[11.5%] right-[9%] md:right-[24%] lg:right-[19%] border border-custom-text-100 rounded-full p-1 translate-x-1/2 -translate-y-1/2 z-10 cursor-pointer"
|
||||
onClick={onComplete}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3 text-custom-text-100" />
|
||||
</button>
|
||||
<TourSidebar step={step} setStep={setStep} />
|
||||
<div className="col-span-10 lg:col-span-7 h-full overflow-hidden">
|
||||
<div
|
||||
className={`flex items-end h-1/2 sm:h-3/5 overflow-hidden bg-custom-primary-100 ${
|
||||
currentStepIndex % 2 === 0 ? "justify-end" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
<Image src={currentStep?.image} alt={currentStep?.title} />
|
||||
</div>
|
||||
<div className="flex flex-col h-1/2 sm:h-2/5 p-4 overflow-y-auto">
|
||||
<h3 className="font-medium text-lg">{currentStep?.title}</h3>
|
||||
<p className="text-custom-text-200 text-sm mt-3">{currentStep?.description}</p>
|
||||
<div className="h-full flex items-end justify-between gap-4 mt-3">
|
||||
<div className="flex items-center gap-4">
|
||||
{currentStep?.prevStep && (
|
||||
<SecondaryButton onClick={() => setStep(currentStep.prevStep ?? "welcome")}>
|
||||
Back
|
||||
</SecondaryButton>
|
||||
)}
|
||||
{currentStep?.nextStep && (
|
||||
<PrimaryButton onClick={() => setStep(currentStep.nextStep ?? "issues")}>
|
||||
Next
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
{TOUR_STEPS.findIndex((tourStep) => tourStep.key === step) ===
|
||||
TOUR_STEPS.length - 1 && (
|
||||
<PrimaryButton onClick={onComplete}>Create my first project</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
70
apps/app/components/onboarding/tour/sidebar.tsx
Normal file
70
apps/app/components/onboarding/tour/sidebar.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
// icons
|
||||
import { ContrastIcon, LayerDiagonalIcon, PeopleGroupIcon, ViewListIcon } from "components/icons";
|
||||
import { DocumentTextIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { TTourSteps } from "./root";
|
||||
|
||||
const sidebarOptions: {
|
||||
key: TTourSteps;
|
||||
icon: any;
|
||||
}[] = [
|
||||
{
|
||||
key: "issues",
|
||||
icon: LayerDiagonalIcon,
|
||||
},
|
||||
{
|
||||
key: "cycles",
|
||||
icon: ContrastIcon,
|
||||
},
|
||||
{
|
||||
key: "modules",
|
||||
icon: PeopleGroupIcon,
|
||||
},
|
||||
{
|
||||
key: "views",
|
||||
icon: ViewListIcon,
|
||||
},
|
||||
{
|
||||
key: "pages",
|
||||
icon: DocumentTextIcon,
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
step: TTourSteps;
|
||||
setStep: React.Dispatch<React.SetStateAction<TTourSteps>>;
|
||||
};
|
||||
|
||||
export const TourSidebar: React.FC<Props> = ({ step, setStep }) => (
|
||||
<div className="hidden lg:block col-span-3 p-8 bg-custom-background-90">
|
||||
<h3 className="font-medium text-lg">
|
||||
Let{"'"}s get started!
|
||||
<br />
|
||||
Get more out of Plane.
|
||||
</h3>
|
||||
<div className="mt-8 space-y-5">
|
||||
{sidebarOptions.map((option) => (
|
||||
<h5
|
||||
key={option.key}
|
||||
className={`pr-2 py-0.5 pl-3 flex items-center gap-2 capitalize font-medium text-sm border-l-[3px] cursor-pointer ${
|
||||
step === option.key
|
||||
? "text-custom-primary-100 border-custom-primary-100"
|
||||
: "text-custom-text-200 border-transparent"
|
||||
}`}
|
||||
onClick={() => setStep(option.key)}
|
||||
>
|
||||
<option.icon
|
||||
className={`h-5 w-5 flex-shrink-0 ${
|
||||
step === option.key ? "text-custom-primary-100" : "text-custom-text-200"
|
||||
}`}
|
||||
color={`${
|
||||
step === option.key ? "rgb(var(--color-primary-100))" : "rgb(var(--color-text-200))"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{option.key}
|
||||
</h5>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
@ -1,7 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
@ -9,8 +11,10 @@ import userService from "services/user.service";
|
||||
// ui
|
||||
import { CustomSelect, Input, PrimaryButton } from "components/ui";
|
||||
// types
|
||||
import { IUser } from "types";
|
||||
// constant
|
||||
import { ICurrentUserResponse, IUser } from "types";
|
||||
// fetch-keys
|
||||
import { CURRENT_USER } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { USER_ROLES } from "constants/workspace";
|
||||
|
||||
const defaultValues: Partial<IUser> = {
|
||||
@ -21,11 +25,9 @@ const defaultValues: Partial<IUser> = {
|
||||
|
||||
type Props = {
|
||||
user?: IUser;
|
||||
setStep: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
setUserRole: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
};
|
||||
|
||||
export const UserDetails: React.FC<Props> = ({ user, setStep, setUserRole }) => {
|
||||
export const UserDetails: React.FC<Props> = ({ user }) => {
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
@ -39,17 +41,40 @@ export const UserDetails: React.FC<Props> = ({ user, setStep, setUserRole }) =>
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: IUser) => {
|
||||
if (!user) return;
|
||||
|
||||
const payload: Partial<IUser> = {
|
||||
...formData,
|
||||
onboarding_step: {
|
||||
...user.onboarding_step,
|
||||
profile_complete: true,
|
||||
},
|
||||
};
|
||||
|
||||
await userService
|
||||
.updateUser(formData)
|
||||
.updateUser(payload)
|
||||
.then(() => {
|
||||
mutate<ICurrentUserResponse>(
|
||||
CURRENT_USER,
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
setToastAlert({
|
||||
title: "User details updated successfully!",
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Details updated successfully.",
|
||||
});
|
||||
setStep(2);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
mutate(CURRENT_USER);
|
||||
});
|
||||
};
|
||||
|
||||
@ -60,90 +85,88 @@ export const UserDetails: React.FC<Props> = ({ user, setStep, setUserRole }) =>
|
||||
last_name: user.last_name,
|
||||
role: user.role,
|
||||
});
|
||||
setUserRole(user.role);
|
||||
}
|
||||
}, [user, reset, setUserRole]);
|
||||
}, [user, reset]);
|
||||
|
||||
return (
|
||||
<form className="flex w-full items-center justify-center" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex w-full max-w-xl flex-col gap-7">
|
||||
<div className="flex flex-col rounded-[10px] bg-custom-background-100 shadow-md">
|
||||
<div className="flex flex-col gap-2 justify-center px-7 pt-7 pb-3.5">
|
||||
<h3 className="text-base font-semibold text-custom-text-100">User Details</h3>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Enter your details as a first step to open your Plane account.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="h-full w-full space-y-7 sm:space-y-10 overflow-y-auto sm:flex sm:flex-col sm:items-start sm:justify-center"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="relative sm:text-lg">
|
||||
<div className="text-custom-primary-100 absolute -top-1 -left-3">{'"'}</div>
|
||||
<h5>Hey there 👋🏻</h5>
|
||||
<h5 className="mt-5 mb-6">Let{"'"}s get you onboard!</h5>
|
||||
<h4 className="text-xl sm:text-2xl font-semibold">Set up your Plane profile.</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between gap-4 px-7 py-3.5 sm:flex-row">
|
||||
<div className="flex flex-col items-start justify-center gap-1 w-full sm:w-1/2">
|
||||
<span className="mb-1.5">First name</span>
|
||||
<Input
|
||||
name="first_name"
|
||||
autoComplete="off"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "First name is required",
|
||||
}}
|
||||
error={errors.first_name}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-center gap-1 w-full sm:w-1/2">
|
||||
<span className="mb-1.5">Last name</span>
|
||||
<Input
|
||||
name="last_name"
|
||||
autoComplete="off"
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Last name is required",
|
||||
}}
|
||||
error={errors.last_name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start justify-center gap-2.5 border-t border-custom-border-100 px-7 pt-3.5 pb-7">
|
||||
<span>What is your role?</span>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
name="role"
|
||||
control={control}
|
||||
rules={{ required: "This field is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={(value: any) => {
|
||||
onChange(value);
|
||||
setUserRole(value ?? null);
|
||||
}}
|
||||
label={value ? value.toString() : "Select your role"}
|
||||
input
|
||||
width="w-full"
|
||||
>
|
||||
{USER_ROLES.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
{errors.role && <span className="text-sm text-red-500">{errors.role.message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-7 sm:w-3/4 md:w-2/5">
|
||||
<div className="space-y-1 text-sm">
|
||||
<label htmlFor="firstName">First Name</label>
|
||||
<Input
|
||||
id="firstName"
|
||||
name="first_name"
|
||||
autoComplete="off"
|
||||
placeholder="Enter your first name..."
|
||||
register={register}
|
||||
validations={{
|
||||
required: "First name is required",
|
||||
}}
|
||||
error={errors.first_name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-center ">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="flex w-1/2 items-center justify-center text-center"
|
||||
size="md"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Updating..." : "Continue"}
|
||||
</PrimaryButton>
|
||||
<div className="space-y-1 text-sm">
|
||||
<label htmlFor="lastName">Last Name</label>
|
||||
<Input
|
||||
id="lastName"
|
||||
name="last_name"
|
||||
autoComplete="off"
|
||||
register={register}
|
||||
placeholder="Enter your last name..."
|
||||
validations={{
|
||||
required: "Last name is required",
|
||||
}}
|
||||
error={errors.last_name}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<span>What{"'"}s your role?</span>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
name="role"
|
||||
control={control}
|
||||
rules={{ required: "This field is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={(val: any) => onChange(val)}
|
||||
label={
|
||||
value ? (
|
||||
value.toString()
|
||||
) : (
|
||||
<span className="text-custom-text-400">Select your role...</span>
|
||||
)
|
||||
}
|
||||
input
|
||||
width="w-full"
|
||||
verticalPosition="top"
|
||||
>
|
||||
{USER_ROLES.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
{errors.role && <span className="text-sm text-red-500">{errors.role.message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PrimaryButton type="submit" size="md" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Updating..." : "Continue"}
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
@ -1,249 +1,50 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Tab } from "@headlessui/react";
|
||||
// services
|
||||
import workspaceService from "services/workspace.service";
|
||||
// ui
|
||||
import { SecondaryButton } from "components/ui";
|
||||
// types
|
||||
import { ICurrentUserResponse, IWorkspaceMemberInvitation } from "types";
|
||||
// fetch-keys
|
||||
import { USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
|
||||
import { ICurrentUserResponse, OnboardingSteps } from "types";
|
||||
// constants
|
||||
import { CreateWorkspaceForm } from "components/workspace";
|
||||
// ui
|
||||
import { PrimaryButton } from "components/ui";
|
||||
import { getFirstCharacters, truncateText } from "helpers/string.helper";
|
||||
|
||||
type Props = {
|
||||
setStep: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
setWorkspace: React.Dispatch<React.SetStateAction<any>>;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
updateLastWorkspace: () => Promise<void>;
|
||||
stepChange: (steps: Partial<OnboardingSteps>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const Workspace: React.FC<Props> = ({ setStep, setWorkspace, user }) => {
|
||||
const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false);
|
||||
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
|
||||
export const Workspace: React.FC<Props> = ({ user, updateLastWorkspace, stepChange }) => {
|
||||
const [defaultValues, setDefaultValues] = useState({
|
||||
name: "",
|
||||
slug: "",
|
||||
company_size: null,
|
||||
organization_size: "",
|
||||
});
|
||||
const [currentTab, setCurrentTab] = useState("create");
|
||||
|
||||
const { data: invitations, mutate } = useSWR(USER_WORKSPACE_INVITATIONS, () =>
|
||||
workspaceService.userWorkspaceInvitations()
|
||||
);
|
||||
const completeStep = async () => {
|
||||
if (!user) return;
|
||||
|
||||
const handleInvitation = (
|
||||
workspace_invitation: IWorkspaceMemberInvitation,
|
||||
action: "accepted" | "withdraw"
|
||||
) => {
|
||||
if (action === "accepted") {
|
||||
setInvitationsRespond((prevData) => [...prevData, workspace_invitation.id]);
|
||||
} else if (action === "withdraw") {
|
||||
setInvitationsRespond((prevData) =>
|
||||
prevData.filter((item: string) => item !== workspace_invitation.id)
|
||||
);
|
||||
}
|
||||
await stepChange({
|
||||
workspace_create: true,
|
||||
});
|
||||
await updateLastWorkspace();
|
||||
};
|
||||
|
||||
const submitInvitations = async () => {
|
||||
if (invitationsRespond.length <= 0) return;
|
||||
setIsJoiningWorkspaces(true);
|
||||
await workspaceService
|
||||
.joinWorkspaces({ invitations: invitationsRespond })
|
||||
.then(async () => {
|
||||
await mutate();
|
||||
setStep(4);
|
||||
setIsJoiningWorkspaces(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsJoiningWorkspaces(false);
|
||||
});
|
||||
};
|
||||
|
||||
const currentTabValue = (tab: string | null) => {
|
||||
switch (tab) {
|
||||
case "join":
|
||||
return 0;
|
||||
case "create":
|
||||
return 1;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
console.log("invitations:", invitations);
|
||||
|
||||
return (
|
||||
<div className="grid w-full place-items-center">
|
||||
<Tab.Group
|
||||
as="div"
|
||||
className="flex h-[442px] w-full max-w-xl flex-col justify-between rounded-[10px] bg-custom-background-100 shadow-md"
|
||||
defaultIndex={currentTabValue(currentTab)}
|
||||
onChange={(i) => {
|
||||
switch (i) {
|
||||
case 0:
|
||||
return setCurrentTab("join");
|
||||
case 1:
|
||||
return setCurrentTab("create");
|
||||
default:
|
||||
return setCurrentTab("create");
|
||||
<div className="w-full space-y-7 sm:space-y-10">
|
||||
<h4 className="text-xl sm:text-2xl font-semibold">Create your workspace</h4>
|
||||
<div className="sm:w-3/4 md:w-2/5">
|
||||
<CreateWorkspaceForm
|
||||
onSubmit={completeStep}
|
||||
defaultValues={defaultValues}
|
||||
setDefaultValues={setDefaultValues}
|
||||
user={user}
|
||||
secondaryButton={
|
||||
<SecondaryButton onClick={() => stepChange({ profile_complete: false })}>
|
||||
Back
|
||||
</SecondaryButton>
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab.List as="div" className="flex flex-col gap-3 px-7 pt-7 pb-3.5">
|
||||
<div className="flex flex-col gap-2 justify-center">
|
||||
<h3 className="text-base font-semibold text-custom-text-100">Workspace</h3>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Create or join the workspace to get started with Plane.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-gray-8 flex items-center justify-start gap-3 text-sm">
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`rounded-3xl border px-4 py-2 outline-none ${
|
||||
selected
|
||||
? "border-custom-primary bg-custom-primary text-white font-medium"
|
||||
: "border-custom-border-100 bg-custom-background-100 hover:bg-custom-background-80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
Invited Workspace
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`rounded-3xl border px-4 py-2 outline-none ${
|
||||
selected
|
||||
? "border-custom-primary bg-custom-primary text-white font-medium"
|
||||
: "border-custom-border-100 bg-custom-background-100 hover:bg-custom-background-80"
|
||||
}`
|
||||
}
|
||||
>
|
||||
New Workspace
|
||||
</Tab>
|
||||
</div>
|
||||
</Tab.List>
|
||||
<Tab.Panels as="div" className="h-full">
|
||||
<Tab.Panel className="h-full">
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="h-[280px] overflow-y-auto px-7">
|
||||
{invitations && invitations.length > 0 ? (
|
||||
invitations.map((invitation) => (
|
||||
<div key={invitation.id}>
|
||||
<label
|
||||
className={`group relative flex cursor-pointer items-start space-x-3 border-2 border-transparent py-4`}
|
||||
htmlFor={invitation.id}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
{invitation.workspace.logo && invitation.workspace.logo !== "" ? (
|
||||
<img
|
||||
src={invitation.workspace.logo}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded"
|
||||
alt={invitation.workspace.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center rounded-xl bg-gray-700 p-4 uppercase text-white">
|
||||
{getFirstCharacters(invitation.workspace.name)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">
|
||||
{truncateText(invitation.workspace.name, 30)}
|
||||
</div>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Invited by{" "}
|
||||
{invitation.created_by_detail
|
||||
? invitation.created_by_detail.first_name
|
||||
: invitation.workspace.owner.first_name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<button
|
||||
className={`${
|
||||
invitationsRespond.includes(invitation.id)
|
||||
? "bg-custom-background-80 text-custom-text-200"
|
||||
: "bg-custom-primary text-white"
|
||||
} text-sm px-4 py-2 border border-custom-border-100 rounded-3xl`}
|
||||
onClick={(e) => {
|
||||
handleInvitation(
|
||||
invitation,
|
||||
invitationsRespond.includes(invitation.id) ? "withdraw" : "accepted"
|
||||
);
|
||||
}}
|
||||
>
|
||||
{invitationsRespond.includes(invitation.id)
|
||||
? "Invitation Accepted"
|
||||
: "Accept Invitation"}
|
||||
</button>
|
||||
{/* <input
|
||||
id={invitation.id}
|
||||
aria-describedby="workspaces"
|
||||
name={invitation.id}
|
||||
value={
|
||||
invitationsRespond.includes(invitation.id)
|
||||
? "Invitation Accepted"
|
||||
: "Accept Invitation"
|
||||
}
|
||||
onClick={(e) => {
|
||||
handleInvitation(
|
||||
invitation,
|
||||
invitationsRespond.includes(invitation.id) ? "withdraw" : "accepted"
|
||||
);
|
||||
}}
|
||||
type="button"
|
||||
className={`${
|
||||
invitationsRespond.includes(invitation.id)
|
||||
? "bg-custom-background-80 text-custom-text-200"
|
||||
: "bg-custom-primary text-white"
|
||||
} text-sm px-4 py-2 border border-custom-border-100 rounded-3xl`}
|
||||
|
||||
// className="h-4 w-4 rounded border-custom-border-100 text-custom-primary focus:ring-custom-primary"
|
||||
/> */}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<h3 className="text-custom-text-200">{`You don't have any invitations yet.`}</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-center rounded-b-[10px] pt-10">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-1/2 text-center"
|
||||
size="md"
|
||||
disabled={isJoiningWorkspaces || invitationsRespond.length === 0}
|
||||
onClick={submitInvitations}
|
||||
>
|
||||
Join Workspace
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="h-full">
|
||||
<CreateWorkspaceForm
|
||||
onSubmit={(res) => {
|
||||
setWorkspace(res);
|
||||
setStep(3);
|
||||
}}
|
||||
defaultValues={defaultValues}
|
||||
setDefaultValues={setDefaultValues}
|
||||
user={user}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -10,8 +10,10 @@ import pagesService from "services/pages.service";
|
||||
import { PagesView } from "components/pages";
|
||||
// ui
|
||||
import { EmptyState, Loader } from "components/ui";
|
||||
// icons
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// images
|
||||
import emptyPage from "public/empty-state/empty-page.svg";
|
||||
import emptyPage from "public/empty-state/page.svg";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
@ -51,10 +53,17 @@ export const RecentPagesList: React.FC<TPagesListProps> = ({ viewType }) => {
|
||||
})
|
||||
) : (
|
||||
<EmptyState
|
||||
type="page"
|
||||
title="Create New Page"
|
||||
description="Create and document issues effortlessly in one place with Plane Notes, AI-powered for ease."
|
||||
imgURL={emptyPage}
|
||||
title="Have your thoughts in place"
|
||||
description="You can think of Pages as an AI-powered notepad."
|
||||
image={emptyPage}
|
||||
buttonText="New Page"
|
||||
buttonIcon={<PlusIcon className="h-4 w-4" />}
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "d",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
@ -18,8 +18,10 @@ import {
|
||||
} from "components/pages";
|
||||
// ui
|
||||
import { EmptyState, Loader } from "components/ui";
|
||||
// icons
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// images
|
||||
import emptyPage from "public/empty-state/empty-page.svg";
|
||||
import emptyPage from "public/empty-state/page.svg";
|
||||
// types
|
||||
import { IPage, TPageViewProps } from "types";
|
||||
import {
|
||||
@ -255,10 +257,17 @@ export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
|
||||
)
|
||||
) : (
|
||||
<EmptyState
|
||||
type="page"
|
||||
title="Create New Page"
|
||||
description="Create and document issues effortlessly in one place with Plane Notes, AI-powered for ease."
|
||||
imgURL={emptyPage}
|
||||
title="Have your thoughts in place"
|
||||
description="You can think of Pages as an AI-powered notepad."
|
||||
image={emptyPage}
|
||||
buttonText="New Page"
|
||||
buttonIcon={<PlusIcon className="h-4 w-4" />}
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "d",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -20,7 +20,11 @@ import {
|
||||
import { ExclamationIcon } from "components/icons";
|
||||
// helpers
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
import { renderShortTime, renderShortDate, renderLongDateFormat } from "helpers/date-time.helper";
|
||||
import {
|
||||
render24HourFormatTime,
|
||||
renderShortDate,
|
||||
renderLongDateFormat,
|
||||
} from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IPage, IProjectMember } from "types";
|
||||
|
||||
@ -97,11 +101,13 @@ export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip
|
||||
tooltipContent={`Last updated at ${
|
||||
renderShortTime(page.updated_at) +
|
||||
render24HourFormatTime(page.updated_at) +
|
||||
` ${new Date(page.updated_at).getHours() < 12 ? "am" : "pm"}`
|
||||
} on ${renderShortDate(page.updated_at)}`}
|
||||
>
|
||||
<p className="text-sm text-custom-text-200">{renderShortTime(page.updated_at)}</p>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
{render24HourFormatTime(page.updated_at)}
|
||||
</p>
|
||||
</Tooltip>
|
||||
{page.is_favorite ? (
|
||||
<button
|
||||
|
@ -21,7 +21,11 @@ import {
|
||||
import { ExclamationIcon } from "components/icons";
|
||||
// helpers
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
import { renderLongDateFormat, renderShortDate, renderShortTime } from "helpers/date-time.helper";
|
||||
import {
|
||||
renderLongDateFormat,
|
||||
renderShortDate,
|
||||
render24HourFormatTime,
|
||||
} from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IPage, IProjectMember } from "types";
|
||||
|
||||
@ -98,12 +102,12 @@ export const SinglePageListItem: React.FC<TSingleStatProps> = ({
|
||||
<div className="ml-2 flex flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip
|
||||
tooltipContent={`Last updated at ${renderShortTime(
|
||||
tooltipContent={`Last updated at ${render24HourFormatTime(
|
||||
page.updated_at
|
||||
)} on ${renderShortDate(page.updated_at)}`}
|
||||
>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
{renderShortTime(page.updated_at)}
|
||||
{render24HourFormatTime(page.updated_at)}
|
||||
</p>
|
||||
</Tooltip>
|
||||
{page.is_favorite ? (
|
||||
|
@ -123,7 +123,7 @@ export const CreateProjectModal: React.FC<Props> = (props) => {
|
||||
.createProject(workspaceSlug as string, payload, user)
|
||||
.then((res) => {
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string),
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
|
||||
(prevData) => [res, ...(prevData ?? [])],
|
||||
false
|
||||
);
|
||||
@ -184,7 +184,7 @@ export const CreateProjectModal: React.FC<Props> = (props) => {
|
||||
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-80 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
|
||||
<Dialog.Panel className="transform rounded-lg bg-custom-background-100 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
|
||||
<div className="relative h-36 w-full rounded-t-lg bg-custom-background-80">
|
||||
{watch("cover_image") !== null && (
|
||||
<img
|
||||
|
@ -1,4 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
@ -13,7 +15,7 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { DangerButton, Input, SecondaryButton } from "components/ui";
|
||||
// types
|
||||
import type { ICurrentUserResponse, IProject, IWorkspace } from "types";
|
||||
import type { ICurrentUserResponse, IProject } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECTS_LIST } from "constants/fetch-keys";
|
||||
|
||||
@ -37,7 +39,8 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
|
||||
const [confirmDeleteMyProject, setConfirmDeleteMyProject] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState<IProject | null>(null);
|
||||
|
||||
const workspaceSlug = (data?.workspace as IWorkspace)?.slug;
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@ -64,26 +67,31 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
|
||||
};
|
||||
|
||||
const handleDeletion = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
if (!data || !workspaceSlug || !canDelete) return;
|
||||
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
if (data.is_favorite)
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: true }),
|
||||
(prevData) => prevData?.filter((project: IProject) => project.id !== data.id),
|
||||
false
|
||||
);
|
||||
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
|
||||
(prevData) => prevData?.filter((project: IProject) => project.id !== data.id),
|
||||
false
|
||||
);
|
||||
|
||||
await projectService
|
||||
.deleteProject(workspaceSlug, data.id, user)
|
||||
.deleteProject(workspaceSlug as string, data.id, user)
|
||||
.then(() => {
|
||||
handleClose();
|
||||
mutate<IProject[]>(PROJECTS_LIST(workspaceSlug), (prevData) =>
|
||||
prevData?.filter((project: IProject) => project.id !== data.id)
|
||||
);
|
||||
|
||||
if (onSuccess) onSuccess();
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Project deleted successfully",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
setIsDeleteLoading(false);
|
||||
});
|
||||
.catch(() => setIsDeleteLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -158,7 +158,7 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
|
||||
<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">
|
||||
|
@ -2,26 +2,19 @@ import React, { useState, FC } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// icons
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useTheme from "hooks/use-theme";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
import useProjects from "hooks/use-projects";
|
||||
// components
|
||||
import { CreateProjectModal, DeleteProjectModal, SingleSidebarProject } from "components/project";
|
||||
// ui
|
||||
import { Loader } from "components/ui";
|
||||
import { DeleteProjectModal, SingleSidebarProject } from "components/project";
|
||||
// icons
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { IFavoriteProject, IProject } from "types";
|
||||
// fetch-keys
|
||||
import { FAVORITE_PROJECTS_LIST, PROJECTS_LIST } from "constants/fetch-keys";
|
||||
import { IProject } from "types";
|
||||
|
||||
export const ProjectSidebarList: FC = () => {
|
||||
const [deleteProjectModal, setDeleteProjectModal] = useState(false);
|
||||
@ -33,93 +26,11 @@ export const ProjectSidebarList: FC = () => {
|
||||
|
||||
const { user } = useUserAuth();
|
||||
|
||||
// states
|
||||
const [isCreateProjectModal, setCreateProjectModal] = useState(false);
|
||||
// theme
|
||||
const { collapsed: sidebarCollapse } = useTheme();
|
||||
// toast handler
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { data: favoriteProjects } = useSWR(
|
||||
workspaceSlug ? FAVORITE_PROJECTS_LIST(workspaceSlug.toString()) : null,
|
||||
() => (workspaceSlug ? projectService.getFavoriteProjects(workspaceSlug.toString()) : null)
|
||||
);
|
||||
|
||||
const { data: projects } = useSWR(
|
||||
workspaceSlug ? PROJECTS_LIST(workspaceSlug as string) : null,
|
||||
() => (workspaceSlug ? projectService.getProjects(workspaceSlug as string) : null)
|
||||
);
|
||||
const normalProjects = projects?.filter((p) => !p.is_favorite) ?? [];
|
||||
|
||||
const handleAddToFavorites = (project: IProject) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
projectService
|
||||
.addProjectToFavorites(workspaceSlug as string, {
|
||||
project: project.id,
|
||||
})
|
||||
.then(() => {
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => ({
|
||||
...p,
|
||||
is_favorite: p.id === project.id ? true : p.is_favorite,
|
||||
})),
|
||||
false
|
||||
);
|
||||
mutate(FAVORITE_PROJECTS_LIST(workspaceSlug as string));
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully added the project to favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the project from favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (project: IProject) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
projectService
|
||||
.removeProjectFromFavorites(workspaceSlug as string, project.id)
|
||||
.then(() => {
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => ({
|
||||
...p,
|
||||
is_favorite: p.id === project.id ? false : p.is_favorite,
|
||||
})),
|
||||
false
|
||||
);
|
||||
mutate<IFavoriteProject[]>(
|
||||
FAVORITE_PROJECTS_LIST(workspaceSlug as string),
|
||||
(prevData) => (prevData ?? []).filter((p) => p.project !== project.id),
|
||||
false
|
||||
);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully removed the project from favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the project from favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
const { projects: favoriteProjects } = useProjects(true);
|
||||
const { projects: allProjects } = useProjects();
|
||||
|
||||
const handleDeleteProject = (project: IProject) => {
|
||||
setProjectToDelete(project);
|
||||
@ -140,93 +51,61 @@ export const ProjectSidebarList: FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateProjectModal
|
||||
isOpen={isCreateProjectModal}
|
||||
setIsOpen={setCreateProjectModal}
|
||||
user={user}
|
||||
/>
|
||||
<DeleteProjectModal
|
||||
isOpen={deleteProjectModal}
|
||||
onClose={() => setDeleteProjectModal(false)}
|
||||
data={projectToDelete}
|
||||
user={user}
|
||||
/>
|
||||
<div className="mt-2.5 h-full overflow-y-auto border-t border-custom-sidebar-border-100 bg-custom-sidebar-background-100 pt-2.5">
|
||||
<div className="h-full overflow-y-auto px-4">
|
||||
{favoriteProjects && favoriteProjects.length > 0 && (
|
||||
<div className="mt-3 flex flex-col space-y-2 px-3">
|
||||
<div className="flex flex-col space-y-2 mt-5">
|
||||
{!sidebarCollapse && (
|
||||
<h5 className="text-sm font-semibold text-custom-sidebar-text-200">Favorites</h5>
|
||||
<h5 className="text-sm font-medium text-custom-sidebar-text-200">Favorites</h5>
|
||||
)}
|
||||
{favoriteProjects.map((favoriteProject) => {
|
||||
const project = favoriteProject.project_detail;
|
||||
|
||||
return (
|
||||
<SingleSidebarProject
|
||||
key={project.id}
|
||||
project={project}
|
||||
sidebarCollapse={sidebarCollapse}
|
||||
handleDeleteProject={() => handleDeleteProject(project)}
|
||||
handleCopyText={() => handleCopyText(project.id)}
|
||||
handleRemoveFromFavorites={() => handleRemoveFromFavorites(project)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{favoriteProjects.map((project) => (
|
||||
<SingleSidebarProject
|
||||
key={project.id}
|
||||
project={project}
|
||||
sidebarCollapse={sidebarCollapse}
|
||||
handleDeleteProject={() => handleDeleteProject(project)}
|
||||
handleCopyText={() => handleCopyText(project.id)}
|
||||
shortContextMenu
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col space-y-2 p-3">
|
||||
{!sidebarCollapse && (
|
||||
<h5 className="text-sm font-semibold text-custom-sidebar-text-200">Projects</h5>
|
||||
)}
|
||||
{projects ? (
|
||||
<>
|
||||
{normalProjects.length > 0 ? (
|
||||
normalProjects.map((project) => (
|
||||
<SingleSidebarProject
|
||||
key={project.id}
|
||||
project={project}
|
||||
sidebarCollapse={sidebarCollapse}
|
||||
handleDeleteProject={() => handleDeleteProject(project)}
|
||||
handleCopyText={() => handleCopyText(project.id)}
|
||||
handleAddToFavorites={() => handleAddToFavorites(project)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="space-y-3 text-center">
|
||||
{!sidebarCollapse && (
|
||||
<h4 className="text-sm text-custom-text-200">
|
||||
You don{"'"}t have any project yet
|
||||
</h4>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center justify-center gap-2 rounded-md bg-custom-background-80 p-2 text-xs text-custom-text-100"
|
||||
onClick={() => setCreateProjectModal(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{!sidebarCollapse && "Create Project"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<Loader className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="15px" width="80%" />
|
||||
<Loader.Item height="15px" width="80%" />
|
||||
<Loader.Item height="15px" width="80%" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="15px" width="80%" />
|
||||
<Loader.Item height="15px" width="80%" />
|
||||
<Loader.Item height="15px" width="80%" />
|
||||
</div>
|
||||
</Loader>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{allProjects && allProjects.length > 0 && (
|
||||
<div className="flex flex-col space-y-2 mt-5">
|
||||
{!sidebarCollapse && (
|
||||
<h5 className="text-sm font-medium text-custom-sidebar-text-200">Projects</h5>
|
||||
)}
|
||||
{allProjects.map((project) => (
|
||||
<SingleSidebarProject
|
||||
key={project.id}
|
||||
project={project}
|
||||
sidebarCollapse={sidebarCollapse}
|
||||
handleDeleteProject={() => handleDeleteProject(project)}
|
||||
handleCopyText={() => handleCopyText(project.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{allProjects && allProjects.length === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-custom-sidebar-text-200 mt-5"
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "p",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
{!sidebarCollapse && "Add Project"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -26,9 +26,9 @@ import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// types
|
||||
import type { IFavoriteProject, IProject } from "types";
|
||||
import type { IProject } from "types";
|
||||
// fetch-keys
|
||||
import { FAVORITE_PROJECTS_LIST, PROJECTS_LIST } from "constants/fetch-keys";
|
||||
import { PROJECTS_LIST } from "constants/fetch-keys";
|
||||
|
||||
export type ProjectCardProps = {
|
||||
project: IProject;
|
||||
@ -55,22 +55,23 @@ export const SingleProjectCard: React.FC<ProjectCardProps> = ({
|
||||
const handleAddToFavorites = () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: true }),
|
||||
(prevData) => [...(prevData ?? []), { ...project, is_favorite: true }],
|
||||
false
|
||||
);
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)),
|
||||
false
|
||||
);
|
||||
|
||||
projectService
|
||||
.addProjectToFavorites(workspaceSlug as string, {
|
||||
project: project.id,
|
||||
})
|
||||
.then(() => {
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => ({
|
||||
...p,
|
||||
is_favorite: p.id === project.id ? true : p.is_favorite,
|
||||
})),
|
||||
false
|
||||
);
|
||||
mutate(FAVORITE_PROJECTS_LIST(workspaceSlug as string));
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
@ -89,24 +90,21 @@ export const SingleProjectCard: React.FC<ProjectCardProps> = ({
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!workspaceSlug || !project) return;
|
||||
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: true }),
|
||||
(prevData) => (prevData ?? []).filter((p) => p.id !== project.id),
|
||||
false
|
||||
);
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: false } : p)),
|
||||
false
|
||||
);
|
||||
|
||||
projectService
|
||||
.removeProjectFromFavorites(workspaceSlug as string, project.id)
|
||||
.then(() => {
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => ({
|
||||
...p,
|
||||
is_favorite: p.id === project.id ? false : p.is_favorite,
|
||||
})),
|
||||
false
|
||||
);
|
||||
mutate<IFavoriteProject[]>(
|
||||
FAVORITE_PROJECTS_LIST(workspaceSlug as string),
|
||||
(prevData) => (prevData ?? []).filter((p) => p.project !== project.id),
|
||||
false
|
||||
);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
|
@ -1,70 +1,64 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
import { CustomMenu, Icon, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
DocumentTextIcon,
|
||||
LinkIcon,
|
||||
StarIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ContrastIcon,
|
||||
LayerDiagonalIcon,
|
||||
PeopleGroupIcon,
|
||||
SettingIcon,
|
||||
ViewListIcon,
|
||||
} from "components/icons";
|
||||
import { LinkIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// types
|
||||
import { IProject } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECTS_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
project: IProject;
|
||||
sidebarCollapse: boolean;
|
||||
handleDeleteProject: () => void;
|
||||
handleCopyText: () => void;
|
||||
handleAddToFavorites?: () => void;
|
||||
handleRemoveFromFavorites?: () => void;
|
||||
shortContextMenu?: boolean;
|
||||
};
|
||||
|
||||
const navigation = (workspaceSlug: string, projectId: string) => [
|
||||
{
|
||||
name: "Issues",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/issues`,
|
||||
icon: LayerDiagonalIcon,
|
||||
icon: "stack",
|
||||
},
|
||||
{
|
||||
name: "Cycles",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
|
||||
icon: ContrastIcon,
|
||||
icon: "contrast",
|
||||
},
|
||||
{
|
||||
name: "Modules",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/modules`,
|
||||
icon: PeopleGroupIcon,
|
||||
icon: "dataset",
|
||||
},
|
||||
{
|
||||
name: "Views",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/views`,
|
||||
icon: ViewListIcon,
|
||||
icon: "photo_filter",
|
||||
},
|
||||
{
|
||||
name: "Pages",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/pages`,
|
||||
icon: DocumentTextIcon,
|
||||
icon: "article",
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/settings`,
|
||||
icon: SettingIcon,
|
||||
icon: "settings",
|
||||
},
|
||||
];
|
||||
|
||||
@ -73,65 +67,132 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
sidebarCollapse,
|
||||
handleDeleteProject,
|
||||
handleCopyText,
|
||||
handleAddToFavorites,
|
||||
handleRemoveFromFavorites,
|
||||
shortContextMenu = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleAddToFavorites = () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: true }),
|
||||
(prevData) => [...(prevData ?? []), { ...project, is_favorite: true }],
|
||||
false
|
||||
);
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)),
|
||||
false
|
||||
);
|
||||
|
||||
projectService
|
||||
.addProjectToFavorites(workspaceSlug as string, {
|
||||
project: project.id,
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the project from favorites. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: true }),
|
||||
(prevData) => (prevData ?? []).filter((p) => p.id !== project.id),
|
||||
false
|
||||
);
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: false } : p)),
|
||||
false
|
||||
);
|
||||
|
||||
projectService.removeProjectFromFavorites(workspaceSlug as string, project.id).catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the project from favorites. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Disclosure key={project?.id} defaultOpen={projectId === project?.id}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-x-1 text-custom-sidebar-text-100">
|
||||
<Disclosure.Button
|
||||
as="div"
|
||||
className={`flex w-full cursor-pointer select-none items-center rounded-md py-2 text-left text-sm font-medium ${
|
||||
sidebarCollapse ? "justify-center" : "justify-between"
|
||||
}`}
|
||||
<Tooltip
|
||||
tooltipContent={`${project?.name}`}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!sidebarCollapse}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{project.emoji ? (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
{renderEmoji(project.emoji)}
|
||||
</span>
|
||||
) : project.icon_prop ? (
|
||||
<div className="h-7 w-7 grid place-items-center">
|
||||
<span
|
||||
style={{ color: project.icon_prop.color }}
|
||||
className="material-symbols-rounded text-lg"
|
||||
>
|
||||
{project.icon_prop.name}
|
||||
<Disclosure.Button
|
||||
as="div"
|
||||
className={`flex w-full cursor-pointer select-none items-center rounded-sm py-1 text-left text-sm font-medium ${
|
||||
sidebarCollapse ? "justify-center" : "justify-between"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{project.emoji ? (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
{renderEmoji(project.emoji)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{project?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
) : project.icon_prop ? (
|
||||
<div className="h-7 w-7 grid place-items-center">
|
||||
<span
|
||||
style={{ color: project.icon_prop.color }}
|
||||
className="material-symbols-rounded text-lg"
|
||||
>
|
||||
{project.icon_prop.name}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{project?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!sidebarCollapse && (
|
||||
<p
|
||||
className={`overflow-hidden text-ellipsis ${
|
||||
open ? "" : "text-custom-sidebar-text-200"
|
||||
}`}
|
||||
>
|
||||
{truncateText(project?.name, 14)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!sidebarCollapse && (
|
||||
<p className="overflow-hidden text-ellipsis text-[0.875rem]">
|
||||
{truncateText(project?.name, 20)}
|
||||
</p>
|
||||
<Icon
|
||||
iconName="expand_more"
|
||||
className={`${open ? "rotate-180" : ""} text-custom-text-200 duration-300`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!sidebarCollapse && (
|
||||
<span>
|
||||
<ChevronDownIcon className={`h-4 w-4 duration-300 ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
)}
|
||||
</Disclosure.Button>
|
||||
</Disclosure.Button>
|
||||
</Tooltip>
|
||||
|
||||
{!sidebarCollapse && (
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={handleDeleteProject}>
|
||||
<span className="flex items-center justify-start gap-2 ">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete project</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
{handleAddToFavorites && (
|
||||
{!shortContextMenu && (
|
||||
<CustomMenu.MenuItem onClick={handleDeleteProject}>
|
||||
<span className="flex items-center justify-start gap-2 ">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete project</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{!project.is_favorite && (
|
||||
<CustomMenu.MenuItem onClick={handleAddToFavorites}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<StarIcon className="h-4 w-4" />
|
||||
@ -139,7 +200,7 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{handleRemoveFromFavorites && (
|
||||
{project.is_favorite && (
|
||||
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<StarIcon className="h-4 w-4" />
|
||||
@ -153,6 +214,18 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
<span>Copy project link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
{project.archive_in > 0 && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
router.push(`/${workspaceSlug}/projects/${project?.id}/archived-issues/`)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Icon iconName="archive" className="h-4 w-4" />
|
||||
<span>Archived Issues</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
@ -165,9 +238,7 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel
|
||||
className={`${sidebarCollapse ? "" : "ml-[2.25rem]"} flex flex-col gap-y-1`}
|
||||
>
|
||||
<Disclosure.Panel className={`space-y-2 ${sidebarCollapse ? "" : "ml-[2.25rem]"}`}>
|
||||
{navigation(workspaceSlug as string, project?.id).map((item) => {
|
||||
if (
|
||||
(item.name === "Cycles" && !project.cycle_view) ||
|
||||
@ -179,25 +250,24 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<Link key={item.name} href={item.href}>
|
||||
<a
|
||||
className={`group flex items-center rounded-md p-2 text-xs font-medium outline-none ${
|
||||
router.asPath.includes(item.href)
|
||||
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 focus:bg-custom-sidebar-background-90"
|
||||
} ${sidebarCollapse ? "justify-center" : ""}`}
|
||||
>
|
||||
<div className="grid place-items-center">
|
||||
<item.icon
|
||||
className={`h-5 w-5 flex-shrink-0 ${!sidebarCollapse ? "mr-3" : ""}`}
|
||||
color={
|
||||
<a className="block w-full">
|
||||
<Tooltip
|
||||
tooltipContent={`${project?.name}: ${item.name}`}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!sidebarCollapse}
|
||||
>
|
||||
<div
|
||||
className={`group flex items-center rounded-md px-2 py-1.5 gap-2 text-xs font-medium outline-none ${
|
||||
router.asPath.includes(item.href)
|
||||
? "rgb(var(--color-sidebar-text-100))"
|
||||
: "rgb(var(--color-sidebar-text-200))"
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{!sidebarCollapse && item.name}
|
||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||
} ${sidebarCollapse ? "justify-center" : ""}`}
|
||||
>
|
||||
<Icon iconName={item.icon} />
|
||||
{!sidebarCollapse && item.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
|
39
apps/app/components/ui/circular-progress.tsx
Normal file
39
apps/app/components/ui/circular-progress.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export const CircularProgress = ({ progress }: { progress: number }) => {
|
||||
const [circumference, setCircumference] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const radius = 40;
|
||||
const calcCircumference = 2 * Math.PI * radius;
|
||||
setCircumference(calcCircumference);
|
||||
}, []);
|
||||
|
||||
const progressAngle = (progress / 100) * 360 >= 360 ? 359.9 : (progress / 100) * 360;
|
||||
const progressX = 50 + Math.cos((progressAngle - 90) * (Math.PI / 180)) * 40;
|
||||
const progressY = 50 + Math.sin((progressAngle - 90) * (Math.PI / 180)) * 40;
|
||||
|
||||
return (
|
||||
<div className="relative h-5 w-5">
|
||||
<svg className="absolute top-0 left-0" viewBox="0 0 100 100">
|
||||
<circle
|
||||
className="stroke-current"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
/>
|
||||
<path
|
||||
className="fill-current"
|
||||
d={`M50 10
|
||||
A40 40 0 ${progress > 50 ? 1 : 0} 1 ${progressX} ${progressY}
|
||||
L50 50 Z`}
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { ChevronDownIcon, EllipsisHorizontalIcon } from "@heroicons/react/24/outline";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@ -59,10 +60,11 @@ const CustomMenu = ({
|
||||
{ellipsis || verticalEllipsis ? (
|
||||
<Menu.Button
|
||||
type="button"
|
||||
className="relative grid place-items-center rounded p-1 text-custom-text-200 hover:bg-custom-background-80 hover:text-custom-text-100 focus:outline-none"
|
||||
className="relative grid place-items-center rounded p-1 text-custom-text-200 hover:bg-custom-background-80 outline-none"
|
||||
>
|
||||
<EllipsisHorizontalIcon
|
||||
className={`h-4 w-4 ${verticalEllipsis ? "rotate-90" : ""}`}
|
||||
<Icon
|
||||
iconName="more_horiz"
|
||||
className={`${verticalEllipsis ? "rotate-90" : ""} text-brand-secondary`}
|
||||
/>
|
||||
</Menu.Button>
|
||||
) : (
|
||||
|
@ -85,7 +85,7 @@ export const CustomSearchSelect = ({
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${
|
||||
input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
|
||||
} items-center justify-between gap-1 rounded-md shadow-sm duration-300 focus:outline-none focus:ring-1 focus:ring-brand-base ${
|
||||
} items-center justify-between gap-1 rounded-md shadow-sm duration-300 focus:outline-none focus:ring-1 focus:ring-custom-border-100 ${
|
||||
textAlignment === "right"
|
||||
? "text-right"
|
||||
: textAlignment === "center"
|
||||
@ -148,8 +148,8 @@ export const CustomSearchSelect = ({
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`${active || selected ? "bg-custom-background-80" : ""} ${
|
||||
selected ? "font-medium" : ""
|
||||
} flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 text-custom-text-200`
|
||||
selected ? "text-custom-text-100" : "text-custom-text-200"
|
||||
} flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5`
|
||||
}
|
||||
>
|
||||
{({ active, selected }) => (
|
||||
@ -157,7 +157,7 @@ export const CustomSearchSelect = ({
|
||||
{option.content}
|
||||
{multiple ? (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded border border-gray-500 p-0.5 ${
|
||||
className={`flex items-center justify-center rounded border border-custom-border-400 p-0.5 ${
|
||||
active || selected ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
|
@ -118,8 +118,8 @@ const Option: React.FC<OptionProps> = ({ children, value, className }) => (
|
||||
value={value}
|
||||
className={({ active, selected }) =>
|
||||
`${className} ${active || selected ? "bg-custom-background-80" : ""} ${
|
||||
selected ? "font-medium" : ""
|
||||
} cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200`
|
||||
selected ? "text-custom-text-100" : "text-custom-text-200"
|
||||
} cursor-pointer select-none truncate rounded px-1 py-1.5`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
|
@ -42,13 +42,13 @@ export const CustomDatePicker: React.FC<Props> = ({
|
||||
: renderAs === "button"
|
||||
? `px-2 py-1 text-xs shadow-sm ${
|
||||
disabled ? "" : "hover:bg-custom-background-80"
|
||||
} duration-300 focus:border-custom-primary focus:outline-none focus:ring-1 focus:ring-custom-primary`
|
||||
} duration-300`
|
||||
: ""
|
||||
} ${error ? "border-red-500 bg-red-100" : ""} ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} ${
|
||||
noBorder ? "" : "border border-custom-border-100"
|
||||
} w-full rounded-md bg-transparent caret-transparent ${className}`}
|
||||
} w-full rounded-md caret-transparent outline-none ${className}`}
|
||||
dateFormat="MMM dd, yyyy"
|
||||
isClearable={isClearable}
|
||||
disabled={disabled}
|
||||
|
@ -64,13 +64,13 @@ const EmptySpaceItem: React.FC<EmptySpaceItemProps> = ({ title, description, Ico
|
||||
<Icon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-custom-text-100">{title}</div>
|
||||
{description ? <div className="text-sm text-custom-text-200">{description}</div> : null}
|
||||
<div className="min-w-0 flex-1 text-custom-text-200">
|
||||
<div className="text-sm font-medium group-hover:text-custom-text-100">{title}</div>
|
||||
{description ? <div className="text-sm">{description}</div> : null}
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<ChevronRightIcon
|
||||
className="h-5 w-5 text-custom-text-100 group-hover:text-custom-text-200"
|
||||
className="h-5 w-5 text-custom-text-200 group-hover:text-custom-text-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,77 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
// ui
|
||||
import { PrimaryButton } from "components/ui";
|
||||
// icon
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// helper
|
||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||
|
||||
type Props = {
|
||||
type: "cycle" | "module" | "project" | "issue" | "view" | "page" | "estimate";
|
||||
title: string;
|
||||
description: React.ReactNode | string;
|
||||
imgURL: string;
|
||||
action?: () => void;
|
||||
image: any;
|
||||
buttonText: string;
|
||||
buttonIcon?: any;
|
||||
onClick?: () => void;
|
||||
isFullScreen?: boolean;
|
||||
};
|
||||
|
||||
export const EmptyState: React.FC<Props> = ({ type, title, description, imgURL, action }) => {
|
||||
const shortcutKey = (type: string) => {
|
||||
switch (type) {
|
||||
case "cycle":
|
||||
return "Q";
|
||||
case "module":
|
||||
return "M";
|
||||
case "project":
|
||||
return "P";
|
||||
case "issue":
|
||||
return "C";
|
||||
case "view":
|
||||
return "V";
|
||||
case "page":
|
||||
return "D";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-5 text-center">
|
||||
<div className="h-32 w-72">
|
||||
<Image src={imgURL} height="128" width="288" alt={type} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold">{title}</h3>
|
||||
{shortcutKey(type) && (
|
||||
<span>
|
||||
Use shortcut{" "}
|
||||
<span className="text-custom-text-200 mx-1 rounded-sm border border-custom-border-100 bg-custom-background-90 px-2 py-1 text-sm font-medium">
|
||||
{shortcutKey(type)}
|
||||
</span>{" "}
|
||||
to create {type} from anywhere.
|
||||
</span>
|
||||
)}
|
||||
<p className="max-w-md text-sm text-custom-text-200">{description}</p>
|
||||
|
||||
<PrimaryButton
|
||||
className="flex items-center gap-1"
|
||||
onClick={() => {
|
||||
if (action) {
|
||||
action();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shortcutKey(type)) return;
|
||||
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: shortcutKey(type) as string,
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 font-bold text-white" />
|
||||
Create New {capitalizeFirstLetter(type)}
|
||||
export const EmptyState: React.FC<Props> = ({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
onClick,
|
||||
buttonText,
|
||||
buttonIcon,
|
||||
isFullScreen = true,
|
||||
}) => (
|
||||
<div
|
||||
className={`h-full w-full mx-auto grid place-items-center p-8 ${
|
||||
isFullScreen ? "md:w-4/5 lg:w-3/5" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="text-center flex flex-col items-center w-full">
|
||||
<Image src={image} className="w-52 sm:w-60" alt={buttonText} />
|
||||
<h6 className="text-xl font-semibold mt-6 sm:mt-8 mb-3">{title}</h6>
|
||||
<p className="text-custom-text-300 mb-7 sm:mb-8">{description}</p>
|
||||
<PrimaryButton className="flex items-center gap-1.5" onClick={onClick}>
|
||||
{buttonIcon}
|
||||
{buttonText}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</div>
|
||||
);
|
||||
|
@ -26,3 +26,4 @@ export * from "./product-updates-modal";
|
||||
export * from "./integration-and-import-export-banner";
|
||||
export * from "./range-datepicker";
|
||||
export * from "./icon";
|
||||
export * from "./circular-progress";
|
||||
|
@ -34,7 +34,7 @@ export const Input: React.FC<Props> = ({
|
||||
register && register(name).onChange(e);
|
||||
onChange && onChange(e);
|
||||
}}
|
||||
className={`block rounded-md bg-transparent text-sm focus:outline-none ${
|
||||
className={`block rounded-md bg-transparent text-sm focus:outline-none placeholder-custom-text-400 ${
|
||||
mode === "primary"
|
||||
? "rounded-md border border-custom-border-100"
|
||||
: mode === "transparent"
|
||||
|
@ -43,7 +43,7 @@ export const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
|
||||
<div>
|
||||
<Menu.Button
|
||||
onClick={() => setOpenChildFor(null)}
|
||||
className={`group flex items-center justify-between gap-2 rounded-md border border-custom-border-100 px-3 py-1.5 text-xs shadow-sm duration-300 focus:outline-none ${
|
||||
className={`group flex items-center justify-between gap-2 rounded-md border border-custom-border-100 px-3 py-1.5 text-xs shadow-sm duration-300 focus:outline-none hover:text-custom-text-100 hover:bg-custom-background-90 ${
|
||||
open ? "bg-custom-background-90 text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
|
@ -66,7 +66,7 @@ export const TextArea: React.FC<Props> = ({
|
||||
onChange && onChange(e);
|
||||
setTextareaValue(e.target.value);
|
||||
}}
|
||||
className={`no-scrollbar w-full bg-transparent ${
|
||||
className={`no-scrollbar w-full bg-transparent placeholder-custom-text-400 ${
|
||||
noPadding ? "" : "px-3 py-2"
|
||||
} outline-none ${
|
||||
mode === "primary"
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user