fix: merge conflict

This commit is contained in:
Dakshesh Jain 2023-07-13 20:37:19 +05:30
commit fd9d76f15f
198 changed files with 5867 additions and 2860 deletions

View File

@ -9,11 +9,11 @@ NEXT_PUBLIC_GITHUB_ID=""
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Sentry DSN for error monitoring
NEXT_PUBLIC_SENTRY_DSN=""
# Enable/Disable OAUTH - default 0 for selfhosted instance
# Enable/Disable OAUTH - default 0 for selfhosted instance
NEXT_PUBLIC_ENABLE_OAUTH=0
# Enable/Disable sentry
NEXT_PUBLIC_ENABLE_SENTRY=0
# Enable/Disable session recording
# Enable/Disable session recording
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
# Enable/Disable event tracking
NEXT_PUBLIC_TRACK_EVENTS=0
@ -59,15 +59,16 @@ 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
# Settings related to Docker
DOCKERIZED=1
# set to 1 If using the pre-configured minio setup
# set to 1 If using the pre-configured minio setup
USE_MINIO=1
# Nginx Configuration
@ -79,4 +80,4 @@ DEFAULT_PASSWORD="password123"
# SignUps
ENABLE_SIGNUP="1"
# Auto generated and Required that will be generated from setup.sh
# Auto generated and Required that will be generated from setup.sh

View File

@ -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
worker: celery -A plane worker -l info
beat: celery -A plane beat -l INFO

View File

@ -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

View File

@ -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",
]

View 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__"

View File

@ -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

View File

@ -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
]

View File

@ -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

View File

@ -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 == "":

View File

@ -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,

View File

@ -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,
)

View 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,
)

View File

@ -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:

View File

@ -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)

View File

@ -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

View 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

View File

@ -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'

View File

@ -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

View File

@ -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):

View 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}>"

View File

@ -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"""

View File

@ -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"

View File

@ -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",)

View File

@ -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)
@ -95,4 +113,4 @@ CELERY_BROKER_URL = os.environ.get("REDIS_URL")
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"

View File

@ -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)

View File

@ -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

View File

@ -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
@ -28,4 +28,5 @@ uvicorn==0.22.0
channels==4.0.0
openai==0.27.8
slack-sdk==3.21.3
celery==5.3.1
celery==5.3.1
django_celery_beat==2.5.0

View File

@ -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}

View File

@ -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>

View 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>
</>
);
};

View 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>
</>
);
};

View File

@ -0,0 +1,3 @@
export * from "./auto-close-automation";
export * from "./auto-archive-automation";
export * from "./select-month-modal";

View 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>
);
};

View File

@ -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">

View File

@ -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>

View File

@ -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"

View File

@ -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">

View File

@ -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}

View File

@ -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"

View File

@ -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>
)}

View File

@ -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" ? (

View File

@ -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>
)}

View File

@ -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>

View File

@ -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>
)}

View File

@ -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>
),

View File

@ -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">

View File

@ -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}

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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>
</>
);

View File

@ -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"

View File

@ -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

View File

@ -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">

View File

@ -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 (
<>

View File

@ -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 (
<>

View File

@ -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">

View File

@ -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}>
<>

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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>

View File

@ -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 (
<>

View File

@ -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}
/>

View File

@ -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}
</>
) : (
<>

View File

@ -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">

View File

@ -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>

View File

@ -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";

View File

@ -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>
);

View 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>
);
};

View File

@ -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>
);

View File

@ -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>
);

View File

@ -0,0 +1,2 @@
export * from "./root";
export * from "./sidebar";

View 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>
)}
</>
);
};

View 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>
);

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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);
}}
/>
)
) : (

View File

@ -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>

View File

@ -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

View File

@ -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 ? (

View File

@ -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

View File

@ -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 (

View File

@ -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">

View File

@ -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>
</>
);

View File

@ -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!",

View File

@ -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>
);

View 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>
);
};

View File

@ -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>
) : (

View File

@ -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"
}`}
>

View File

@ -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 }) => (

View File

@ -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}

View File

@ -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>

View File

@ -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>
);

View File

@ -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";

View File

@ -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"

View File

@ -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"
}`}
>

View File

@ -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