From 8581226e60bc66ff011d544aeabab36890bc6ecc Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 29 Aug 2023 13:34:38 +0530 Subject: [PATCH 01/27] chore: improve access field for comments for public boards (#1956) Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> --- apiserver/plane/api/serializers/issue.py | 2 +- apiserver/plane/api/views/issue.py | 37 ++++++++++++++++-------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 64ee2b8f7..647b299fd 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -569,7 +569,7 @@ class IssueCommentSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(read_only=True, source="project") workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) - + is_member = serializers.BooleanField(read_only=True) class Meta: model = IssueComment diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 0b08bb14f..ff8dab801 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -77,6 +77,7 @@ from plane.db.models import ( from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters +from plane.bgtasks.export_task import issue_export_task class IssueViewSet(BaseViewSet): @@ -588,6 +589,15 @@ class IssueCommentViewSet(BaseViewSet): .select_related("project") .select_related("workspace") .select_related("issue") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + member_id=self.request.user.id, + ) + ) + ) .distinct() ) @@ -769,7 +779,9 @@ class SubIssuesEndpoint(BaseAPIView): .order_by("state_group") ) - result = {item["state_group"]: item["state_count"] for item in state_distribution} + result = { + item["state_group"]: item["state_count"] for item in state_distribution + } serializer = IssueLiteSerializer( sub_issues, @@ -1482,6 +1494,15 @@ class IssueCommentPublicViewSet(BaseViewSet): .select_related("project") .select_related("workspace") .select_related("issue") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + member_id=self.request.user.id, + ) + ) + ) .distinct() ) else: @@ -1499,21 +1520,13 @@ class IssueCommentPublicViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - access = ( - "INTERNAL" - if ProjectMember.objects.filter( - project_id=project_id, member=request.user - ).exists() - else "EXTERNAL" - ) - serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( project_id=project_id, issue_id=issue_id, actor=request.user, - access=access, + access="EXTERNAL", ) issue_activity.delay( type="comment.activity.created", @@ -1567,7 +1580,8 @@ class IssueCommentPublicViewSet(BaseViewSet): except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist): return Response( {"error": "IssueComent Does not exists"}, - status=status.HTTP_400_BAD_REQUEST,) + status=status.HTTP_400_BAD_REQUEST, + ) def destroy(self, request, slug, project_id, issue_id, pk): try: @@ -1826,4 +1840,3 @@ class IssueVotePublicViewSet(BaseViewSet): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) - From 3a41ec74427bb9bcdad0ebe2eac7f4ec4c880180 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 29 Aug 2023 13:35:13 +0530 Subject: [PATCH 02/27] chore: update user activity endpoint to return only workspace activities (#1980) --- apiserver/plane/api/urls.py | 2 +- apiserver/plane/api/views/user.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index a6beac693..64fb4b025 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -235,7 +235,7 @@ urlpatterns = [ UpdateUserTourCompletedEndpoint.as_view(), name="user-tour", ), - path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"), + path("users/workspaces/<str:slug>/activities/", UserActivityEndpoint.as_view(), name="user-activities"), # user workspaces path( "users/me/workspaces/", diff --git a/apiserver/plane/api/views/user.py b/apiserver/plane/api/views/user.py index 84ee47e42..68958e504 100644 --- a/apiserver/plane/api/views/user.py +++ b/apiserver/plane/api/views/user.py @@ -137,11 +137,11 @@ class UpdateUserTourCompletedEndpoint(BaseAPIView): class UserActivityEndpoint(BaseAPIView, BasePaginator): - def get(self, request): + def get(self, request, slug): try: - queryset = IssueActivity.objects.filter(actor=request.user).select_related( - "actor", "workspace", "issue", "project" - ) + queryset = IssueActivity.objects.filter( + actor=request.user, workspace__slug=slug + ).select_related("actor", "workspace", "issue", "project") return self.paginate( request=request, From abcdebef8511ccb2fcd444126a08ed2f97c9a00e Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 29 Aug 2023 13:35:36 +0530 Subject: [PATCH 03/27] fix: n+1 in issue history and issue automation tasks (#1994) --- apiserver/plane/api/views/issue.py | 6 ++++++ apiserver/plane/bgtasks/issue_automation_task.py | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index ff8dab801..e704969fd 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -493,6 +493,12 @@ class IssueActivityEndpoint(BaseAPIView): .filter(project__project_projectmember__member=self.request.user) .order_by("created_at") .select_related("actor", "issue", "project", "workspace") + .prefetch_related( + Prefetch( + "comment_reactions", + queryset=CommentReaction.objects.select_related("actor"), + ) + ) ) issue_activities = IssueActivitySerializer(issue_activities, many=True).data issue_comments = IssueCommentSerializer(issue_comments, many=True).data diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 0e3ead65d..a1f4a3e92 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -64,7 +64,7 @@ def archive_old_issues(): issues_to_update.append(issue) # Bulk Update the issues and log the activity - Issue.objects.bulk_update( + updated_issues = Issue.objects.bulk_update( issues_to_update, ["archived_at"], batch_size=100 ) [ @@ -77,7 +77,7 @@ def archive_old_issues(): current_instance=None, subscriber=False, ) - for issue in issues_to_update + for issue in updated_issues ] return except Exception as e: @@ -136,7 +136,7 @@ def close_old_issues(): issues_to_update.append(issue) # Bulk Update the issues and log the activity - Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) + updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) [ issue_activity.delay( type="issue.activity.updated", @@ -147,7 +147,7 @@ def close_old_issues(): current_instance=None, subscriber=False, ) - for issue in issues_to_update + for issue in updated_issues ] return except Exception as e: From e1ad38568826698adb5f7261642c735e92f587f5 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 29 Aug 2023 13:45:04 +0530 Subject: [PATCH 04/27] fix: issue exports in self hosted instances (#1996) * fix: issue exports in self hosted instances * dev: remove print logs * dev: update url creation function * fix: changed the presigned url for self hosted exports --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> --- apiserver/plane/bgtasks/export_task.py | 81 +++++++++++++------ .../plane/bgtasks/exporter_expired_task.py | 29 ++++--- 2 files changed, 75 insertions(+), 35 deletions(-) diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index 22a9afe51..a45120eb5 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -4,6 +4,7 @@ import io import json import boto3 import zipfile +from urllib.parse import urlparse, urlunparse # Django imports from django.conf import settings @@ -23,9 +24,11 @@ def dateTimeConverter(time): if time: return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z") + def dateConverter(time): if time: - return time.strftime("%a, %d %b %Y") + return time.strftime("%a, %d %b %Y") + def create_csv_file(data): csv_buffer = io.StringIO() @@ -66,28 +69,53 @@ def create_zip_file(files): def upload_to_s3(zip_file, workspace_id, token_id, slug): - s3 = boto3.client( - "s3", - region_name=settings.AWS_REGION, - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - config=Config(signature_version="s3v4"), - ) file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip" - - s3.upload_fileobj( - zip_file, - settings.AWS_S3_BUCKET_NAME, - file_name, - ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"}, - ) - expires_in = 7 * 24 * 60 * 60 - presigned_url = s3.generate_presigned_url( - "get_object", - Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name}, - ExpiresIn=expires_in, - ) + + if settings.DOCKERIZED and settings.USE_MINIO: + s3 = boto3.client( + "s3", + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + s3.upload_fileobj( + zip_file, + settings.AWS_STORAGE_BUCKET_NAME, + file_name, + ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"}, + ) + presigned_url = s3.generate_presigned_url( + "get_object", + Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name}, + ExpiresIn=expires_in, + ) + # Create the new url with updated domain and protocol + presigned_url = presigned_url.replace( + "http://plane-minio:9000/uploads/", + f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/", + ) + else: + s3 = boto3.client( + "s3", + region_name=settings.AWS_REGION, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + s3.upload_fileobj( + zip_file, + settings.AWS_S3_BUCKET_NAME, + file_name, + ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"}, + ) + + presigned_url = s3.generate_presigned_url( + "get_object", + Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name}, + ExpiresIn=expires_in, + ) exporter_instance = ExporterHistory.objects.get(token=token_id) @@ -98,7 +126,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): else: exporter_instance.status = "failed" - exporter_instance.save(update_fields=["status", "url","key"]) + exporter_instance.save(update_fields=["status", "url", "key"]) def generate_table_row(issue): @@ -145,7 +173,7 @@ def generate_json_row(issue): else "", "Labels": issue["labels__name"], "Cycle Name": issue["issue_cycle__cycle__name"], - "Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]), + "Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]), "Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]), "Module Name": issue["issue_module__module__name"], "Module Start Date": dateConverter(issue["issue_module__module__start_date"]), @@ -242,7 +270,9 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s workspace_issues = ( ( Issue.objects.filter( - workspace__id=workspace_id, project_id__in=project_ids + workspace__id=workspace_id, + project_id__in=project_ids, + project__project_projectmember__member=exporter_instance.initiated_by_id, ) .select_related("project", "workspace", "state", "parent", "created_by") .prefetch_related( @@ -275,7 +305,7 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s "labels__name", ) ) - .order_by("project__identifier","sequence_id") + .order_by("project__identifier", "sequence_id") .distinct() ) # CSV header @@ -338,7 +368,6 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s exporter_instance.status = "failed" exporter_instance.reason = str(e) exporter_instance.save(update_fields=["status", "reason"]) - # Print logs if in DEBUG mode if settings.DEBUG: print(e) diff --git a/apiserver/plane/bgtasks/exporter_expired_task.py b/apiserver/plane/bgtasks/exporter_expired_task.py index 799904347..a77d68b4b 100644 --- a/apiserver/plane/bgtasks/exporter_expired_task.py +++ b/apiserver/plane/bgtasks/exporter_expired_task.py @@ -21,18 +21,29 @@ def delete_old_s3_link(): expired_exporter_history = ExporterHistory.objects.filter( Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8)) ).values_list("key", "id") - - s3 = boto3.client( - "s3", - region_name="ap-south-1", - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - config=Config(signature_version="s3v4"), - ) + if settings.DOCKERIZED and settings.USE_MINIO: + s3 = boto3.client( + "s3", + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + else: + s3 = boto3.client( + "s3", + region_name="ap-south-1", + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) for file_name, exporter_id in expired_exporter_history: # Delete object from S3 if file_name: - s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name) + if settings.DOCKERIZED and settings.USE_MINIO: + s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name) + else: + s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name) ExporterHistory.objects.filter(id=exporter_id).update(url=None) From 5ad5da4fd7c0c364edc6919d5ce32dae954e4d22 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 29 Aug 2023 13:45:25 +0530 Subject: [PATCH 05/27] dev: remove gunicorn config (#1999) --- apiserver/Procfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/Procfile b/apiserver/Procfile index 694c49df4..63736e8e8 100644 --- a/apiserver/Procfile +++ b/apiserver/Procfile @@ -1,3 +1,3 @@ -web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - +web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --max-requests 10000 --max-requests-jitter 1000 --access-logfile - worker: celery -A plane worker -l info beat: celery -A plane beat -l INFO \ No newline at end of file From 91c10930a424b97f383b7b6943025e2fd1aeb24d Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Tue, 29 Aug 2023 14:57:27 +0530 Subject: [PATCH 06/27] feat: mark all read notifications (#1963) * feat: mark all read notifications * fix: changed string to boolean * fix: changed snoozed condition --- apiserver/plane/api/urls.py | 10 +++ apiserver/plane/api/views/__init__.py | 2 +- apiserver/plane/api/views/notification.py | 93 ++++++++++++++++++++++- 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 64fb4b025..483bf99a4 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -164,6 +164,7 @@ from plane.api.views import ( # Notification NotificationViewSet, UnreadNotificationEndpoint, + MarkAllReadNotificationViewSet, ## End Notification # Public Boards ProjectDeployBoardViewSet, @@ -1494,6 +1495,15 @@ urlpatterns = [ UnreadNotificationEndpoint.as_view(), name="unread-notifications", ), + path( + "workspaces/<str:slug>/users/notifications/mark-all-read/", + MarkAllReadNotificationViewSet.as_view( + { + "post": "create", + } + ), + name="mark-all-read-notifications", + ), ## End Notification # Public Boards path( diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 9572c552f..47e7c6f85 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -162,7 +162,7 @@ from .analytic import ( DefaultAnalyticsEndpoint, ) -from .notification import NotificationViewSet, UnreadNotificationEndpoint +from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet from .exporter import ( ExportIssuesEndpoint, diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/api/views/notification.py index 2abc82631..75b94f034 100644 --- a/apiserver/plane/api/views/notification.py +++ b/apiserver/plane/api/views/notification.py @@ -10,7 +10,13 @@ from plane.utils.paginator import BasePaginator # Module imports from .base import BaseViewSet, BaseAPIView -from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue, WorkspaceMember +from plane.db.models import ( + Notification, + IssueAssignee, + IssueSubscriber, + Issue, + WorkspaceMember, +) from plane.api.serializers import NotificationSerializer @@ -83,13 +89,17 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # Created issues if type == "created": - if WorkspaceMember.objects.filter(workspace__slug=slug, member=request.user, role__lt=15).exists(): + if WorkspaceMember.objects.filter( + workspace__slug=slug, member=request.user, role__lt=15 + ).exists(): notifications = Notification.objects.none() else: issue_ids = Issue.objects.filter( workspace__slug=slug, created_by=request.user ).values_list("pk", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) + notifications = notifications.filter( + entity_identifier__in=issue_ids + ) # Pagination if request.GET.get("per_page", False) and request.GET.get("cursor", False): @@ -274,3 +284,80 @@ class UnreadNotificationEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class MarkAllReadNotificationViewSet(BaseViewSet): + def create(self, request, slug): + try: + snoozed = request.data.get("snoozed", False) + archived = request.data.get("archived", False) + type = request.data.get("type", "all") + + notifications = ( + Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + read_at__isnull=True, + ) + .select_related("workspace", "project", "triggered_by", "receiver") + .order_by("snoozed_till", "-created_at") + ) + + # Filter for snoozed notifications + if snoozed: + notifications = notifications.filter( + Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False) + ) + else: + notifications = notifications.filter( + Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + ) + + # Filter for archived or unarchive + if archived: + notifications = notifications.filter(archived_at__isnull=False) + else: + notifications = notifications.filter(archived_at__isnull=True) + + # Subscribed issues + if type == "watching": + issue_ids = IssueSubscriber.objects.filter( + workspace__slug=slug, subscriber_id=request.user.id + ).values_list("issue_id", flat=True) + notifications = notifications.filter(entity_identifier__in=issue_ids) + + # Assigned Issues + if type == "assigned": + issue_ids = IssueAssignee.objects.filter( + workspace__slug=slug, assignee_id=request.user.id + ).values_list("issue_id", flat=True) + notifications = notifications.filter(entity_identifier__in=issue_ids) + + # Created issues + if type == "created": + if WorkspaceMember.objects.filter( + workspace__slug=slug, member=request.user, role__lt=15 + ).exists(): + notifications = Notification.objects.none() + else: + issue_ids = Issue.objects.filter( + workspace__slug=slug, created_by=request.user + ).values_list("pk", flat=True) + notifications = notifications.filter( + entity_identifier__in=issue_ids + ) + + updated_notifications = [] + for notification in notifications: + notification.read_at = timezone.now() + updated_notifications.append(notification) + Notification.objects.bulk_update( + updated_notifications, ["read_at"], batch_size=100 + ) + return Response({"message": "Successful"}, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) From 1d30a9a0a8ef8d1d8dc0406f54866c3a712fb22f Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 29 Aug 2023 15:00:26 +0530 Subject: [PATCH 07/27] chore: project public board issue retrieve (#2003) * chore: project public board issue retrieve * dev: project issues list endpoint * fix: issue public retrieve endpoint --- apiserver/plane/api/serializers/__init__.py | 1 + apiserver/plane/api/serializers/issue.py | 32 +++- apiserver/plane/api/urls.py | 10 +- apiserver/plane/api/views/__init__.py | 3 +- apiserver/plane/api/views/issue.py | 174 ++++++++++++++++++++ apiserver/plane/api/views/project.py | 148 ----------------- 6 files changed, 214 insertions(+), 154 deletions(-) diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 5855f0413..93b21a7f2 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -44,6 +44,7 @@ from .issue import ( IssueReactionSerializer, CommentReactionSerializer, IssueVoteSerializer, + IssuePublicSerializer, ) from .module import ( diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 647b299fd..07afc91bc 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -113,7 +113,11 @@ class IssueCreateSerializer(BaseSerializer): ] def validate(self, data): - if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None): + if ( + data.get("start_date", None) is not None + and data.get("target_date", None) is not None + and data.get("start_date", None) > data.get("target_date", None) + ): raise serializers.ValidationError("Start date cannot exceed target date") return data @@ -554,9 +558,7 @@ class CommentReactionSerializer(BaseSerializer): read_only_fields = ["workspace", "project", "comment", "actor"] - class IssueVoteSerializer(BaseSerializer): - class Meta: model = IssueVote fields = ["issue", "vote", "workspace_id", "project_id", "actor"] @@ -676,6 +678,30 @@ class IssueLiteSerializer(BaseSerializer): ] +class IssuePublicSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(read_only=True, source="project") + state_detail = StateLiteSerializer(read_only=True, source="state") + issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description_html", + "sequence_id", + "state", + "state_detail", + "project", + "project_detail", + "workspace", + "priority", + "target_date", + "issue_reactions", + ] + read_only_fields = fields + + class IssueSubscriberSerializer(BaseSerializer): class Meta: model = IssueSubscriber diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 483bf99a4..1fb2b8e90 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -168,13 +168,14 @@ from plane.api.views import ( ## End Notification # Public Boards ProjectDeployBoardViewSet, - ProjectDeployBoardIssuesPublicEndpoint, + ProjectIssuesPublicEndpoint, ProjectDeployBoardPublicSettingsEndpoint, IssueReactionPublicViewSet, CommentReactionPublicViewSet, InboxIssuePublicViewSet, IssueVotePublicViewSet, WorkspaceProjectDeployBoardEndpoint, + IssueRetrievePublicEndpoint, ## End Public Boards ## Exporter ExportIssuesEndpoint, @@ -1534,9 +1535,14 @@ urlpatterns = [ ), path( "public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/", - ProjectDeployBoardIssuesPublicEndpoint.as_view(), + ProjectIssuesPublicEndpoint.as_view(), name="project-deploy-board", ), + path( + "public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/", + IssueRetrievePublicEndpoint.as_view(), + name="workspace-project-boards", + ), path( "public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/", IssueCommentPublicViewSet.as_view( diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 47e7c6f85..b697741ae 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -12,7 +12,6 @@ from .project import ( ProjectUserViewsEndpoint, ProjectMemberUserEndpoint, ProjectFavoritesViewSet, - ProjectDeployBoardIssuesPublicEndpoint, ProjectDeployBoardViewSet, ProjectDeployBoardPublicSettingsEndpoint, ProjectMemberEndpoint, @@ -85,6 +84,8 @@ from .issue import ( IssueReactionPublicViewSet, CommentReactionPublicViewSet, IssueVotePublicViewSet, + IssueRetrievePublicEndpoint, + ProjectIssuesPublicEndpoint, ) from .auth_extended import ( diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index e704969fd..4ed109a80 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -28,6 +28,7 @@ from django.conf import settings from rest_framework.response import Response from rest_framework import status from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.permissions import AllowAny from sentry_sdk import capture_exception # Module imports @@ -49,6 +50,7 @@ from plane.api.serializers import ( IssueReactionSerializer, CommentReactionSerializer, IssueVoteSerializer, + IssuePublicSerializer, ) from plane.api.permissions import ( WorkspaceEntityPermission, @@ -1846,3 +1848,175 @@ class IssueVotePublicViewSet(BaseViewSet): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class IssueRetrievePublicEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, slug, project_id, issue_id): + try: + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=issue_id + ) + serializer = IssuePublicSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + except Issue.DoesNotExist: + return Response( + {"error": "Issue Does not exist"}, status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + print(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class ProjectIssuesPublicEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, slug, project_id): + try: + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + + filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", None] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = ( + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project", "workspace", "state", "parent") + .prefetch_related("assignees", "labels") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order + if order_by_param == "priority" + else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" if order_by_param.startswith("-") else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + issues = IssuePublicSerializer(issue_queryset, many=True).data + + states = State.objects.filter( + workspace__slug=slug, project_id=project_id + ).values("name", "group", "color", "id") + + labels = Label.objects.filter( + workspace__slug=slug, project_id=project_id + ).values("id", "name", "color", "parent") + + ## Grouping the results + group_by = request.GET.get("group_by", False) + if group_by: + issues = group_results(issues, group_by) + + return Response( + { + "issues": issues, + "states": states, + "labels": labels, + }, + status=status.HTTP_200_OK, + ) + except ProjectDeployBoard.DoesNotExist: + return Response( + {"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) \ No newline at end of file diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 3e5ca1c4b..97b06fce5 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -1143,154 +1143,6 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView): ) -class ProjectDeployBoardIssuesPublicEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def get(self, request, slug, project_id): - try: - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id - ) - - filters = issue_filters(request.query_params, "GET") - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", None] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] - - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = ( - Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(project_id=project_id) - .filter(workspace__slug=slug) - .select_related("project", "workspace", "state", "parent") - .prefetch_related("assignees", "labels") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ) - - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssueLiteSerializer(issue_queryset, many=True).data - - states = State.objects.filter( - workspace__slug=slug, project_id=project_id - ).values("name", "group", "color", "id") - - labels = Label.objects.filter( - workspace__slug=slug, project_id=project_id - ).values("id", "name", "color", "parent") - - ## Grouping the results - group_by = request.GET.get("group_by", False) - if group_by: - issues = group_results(issues, group_by) - - return Response( - { - "issues": issues, - "states": states, - "labels": labels, - }, - status=status.HTTP_200_OK, - ) - except ProjectDeployBoard.DoesNotExist: - return Response( - {"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND - ) - except Exception as e: - capture_exception(e) - return Response( - {"error": "Something went wrong please try again later"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): permission_classes = [AllowAny,] From 1cf5e8d80a980d2f241b853fbca50d55ced6b705 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Tue, 29 Aug 2023 15:01:18 +0530 Subject: [PATCH 08/27] fix: only external comments will show in deploy boards (#2010) --- apiserver/plane/api/views/issue.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 4ed109a80..616cef0d6 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -1499,6 +1499,7 @@ class IssueCommentPublicViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(issue_id=self.kwargs.get("issue_id")) + .filter(access="EXTERNAL") .select_related("project") .select_related("workspace") .select_related("issue") From b2a41d3bf67ce46f712ed0b57007de50be2cf49c Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Tue, 29 Aug 2023 15:02:29 +0530 Subject: [PATCH 09/27] fix: issue votes (#2006) * fix: issue votes * fix: added default as 1 in vote * fix: issue vote migration file --- apiserver/plane/api/views/issue.py | 3 ++- ..._together_alter_issuevote_vote_and_more.py | 26 +++++++++++++++++++ apiserver/plane/db/models/issue.py | 6 +++-- 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 apiserver/plane/db/migrations/0042_alter_issuevote_unique_together_alter_issuevote_vote_and_more.py diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 616cef0d6..05434aec5 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -1822,8 +1822,9 @@ class IssueVotePublicViewSet(BaseViewSet): actor_id=request.user.id, project_id=project_id, issue_id=issue_id, - vote=request.data.get("vote", 1), ) + issue_vote.vote = request.data.get("vote", 1) + issue_vote.save() serializer = IssueVoteSerializer(issue_vote) return Response(serializer.data, status=status.HTTP_201_CREATED) except Exception as e: diff --git a/apiserver/plane/db/migrations/0042_alter_issuevote_unique_together_alter_issuevote_vote_and_more.py b/apiserver/plane/db/migrations/0042_alter_issuevote_unique_together_alter_issuevote_vote_and_more.py new file mode 100644 index 000000000..d8063acc0 --- /dev/null +++ b/apiserver/plane/db/migrations/0042_alter_issuevote_unique_together_alter_issuevote_vote_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.3 on 2023-08-29 07:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0041_cycle_sort_order_issuecomment_access_and_more'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='issuevote', + unique_together=set(), + ), + migrations.AlterField( + model_name='issuevote', + name='vote', + field=models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')], default=1), + ), + migrations.AlterUniqueTogether( + name='issuevote', + unique_together={('issue', 'actor', 'vote')}, + ), + ] diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 7af9e6e14..1633cbaf9 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -476,10 +476,12 @@ class IssueVote(ProjectBaseModel): choices=( (-1, "DOWNVOTE"), (1, "UPVOTE"), - ) + ), + default=1, ) + class Meta: - unique_together = ["issue", "actor"] + unique_together = ["issue", "actor", "vote"] verbose_name = "Issue Vote" verbose_name_plural = "Issue Votes" db_table = "issue_votes" From 90cf39cf59dd20d2abbf4752f42d98b1da221e1a Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 29 Aug 2023 16:40:28 +0530 Subject: [PATCH 10/27] fix: access creation in comments (#2013) --- apiserver/plane/api/serializers/issue.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 07afc91bc..2a75b2f48 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -584,7 +584,6 @@ class IssueCommentSerializer(BaseSerializer): "updated_by", "created_at", "updated_at", - "access", ] From 38a5623c43c74b6480f59bf4a3a9b987167f7dc0 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 29 Aug 2023 20:11:06 +0530 Subject: [PATCH 11/27] dev: user timezone select option (#2002) --- apps/app/constants/timezones.ts | 2386 +++++++++++++++++ .../[workspaceSlug]/me/profile/index.tsx | 47 +- apps/app/types/users.d.ts | 1 + 3 files changed, 2433 insertions(+), 1 deletion(-) create mode 100644 apps/app/constants/timezones.ts diff --git a/apps/app/constants/timezones.ts b/apps/app/constants/timezones.ts new file mode 100644 index 000000000..0ba1df4c0 --- /dev/null +++ b/apps/app/constants/timezones.ts @@ -0,0 +1,2386 @@ +export const TIME_ZONES = [ + { + label: "Africa/Abidjan, GMT", + value: "Africa/Abidjan", + }, + { + label: "Africa/Accra, GMT", + value: "Africa/Accra", + }, + { + label: "Africa/Addis_Ababa, GMT+03:00", + value: "Africa/Addis_Ababa", + }, + { + label: "Africa/Algiers, GMT+01:00", + value: "Africa/Algiers", + }, + { + label: "Africa/Asmara, GMT+03:00", + value: "Africa/Asmara", + }, + { + label: "Africa/Asmera, GMT+03:00", + value: "Africa/Asmera", + }, + { + label: "Africa/Bamako, GMT", + value: "Africa/Bamako", + }, + { + label: "Africa/Bangui, GMT+01:00", + value: "Africa/Bangui", + }, + { + label: "Africa/Banjul, GMT", + value: "Africa/Banjul", + }, + { + label: "Africa/Bissau, GMT", + value: "Africa/Bissau", + }, + { + label: "Africa/Blantyre, GMT+02:00", + value: "Africa/Blantyre", + }, + { + label: "Africa/Brazzaville, GMT+01:00", + value: "Africa/Brazzaville", + }, + { + label: "Africa/Bujumbura, GMT+02:00", + value: "Africa/Bujumbura", + }, + { + label: "Africa/Cairo, GMT+03:00", + value: "Africa/Cairo", + }, + { + label: "Africa/Casablanca, GMT+01:00", + value: "Africa/Casablanca", + }, + { + label: "Africa/Ceuta, GMT+02:00", + value: "Africa/Ceuta", + }, + { + label: "Africa/Conakry, GMT", + value: "Africa/Conakry", + }, + { + label: "Africa/Dakar, GMT", + value: "Africa/Dakar", + }, + { + label: "Africa/Dar_es_Salaam, GMT+03:00", + value: "Africa/Dar_es_Salaam", + }, + { + label: "Africa/Djibouti, GMT+03:00", + value: "Africa/Djibouti", + }, + { + label: "Africa/Douala, GMT+01:00", + value: "Africa/Douala", + }, + { + label: "Africa/El_Aaiun, GMT+01:00", + value: "Africa/El_Aaiun", + }, + { + label: "Africa/Freetown, GMT", + value: "Africa/Freetown", + }, + { + label: "Africa/Gaborone, GMT+02:00", + value: "Africa/Gaborone", + }, + { + label: "Africa/Harare, GMT+02:00", + value: "Africa/Harare", + }, + { + label: "Africa/Johannesburg, GMT+02:00", + value: "Africa/Johannesburg", + }, + { + label: "Africa/Juba, GMT+02:00", + value: "Africa/Juba", + }, + { + label: "Africa/Kampala, GMT+03:00", + value: "Africa/Kampala", + }, + { + label: "Africa/Khartoum, GMT+02:00", + value: "Africa/Khartoum", + }, + { + label: "Africa/Kigali, GMT+02:00", + value: "Africa/Kigali", + }, + { + label: "Africa/Kinshasa, GMT+01:00", + value: "Africa/Kinshasa", + }, + { + label: "Africa/Lagos, GMT+01:00", + value: "Africa/Lagos", + }, + { + label: "Africa/Libreville, GMT+01:00", + value: "Africa/Libreville", + }, + { + label: "Africa/Lome, GMT", + value: "Africa/Lome", + }, + { + label: "Africa/Luanda, GMT+01:00", + value: "Africa/Luanda", + }, + { + label: "Africa/Lubumbashi, GMT+02:00", + value: "Africa/Lubumbashi", + }, + { + label: "Africa/Lusaka, GMT+02:00", + value: "Africa/Lusaka", + }, + { + label: "Africa/Malabo, GMT+01:00", + value: "Africa/Malabo", + }, + { + label: "Africa/Maputo, GMT+02:00", + value: "Africa/Maputo", + }, + { + label: "Africa/Maseru, GMT+02:00", + value: "Africa/Maseru", + }, + { + label: "Africa/Mbabane, GMT+02:00", + value: "Africa/Mbabane", + }, + { + label: "Africa/Mogadishu, GMT+03:00", + value: "Africa/Mogadishu", + }, + { + label: "Africa/Monrovia, GMT", + value: "Africa/Monrovia", + }, + { + label: "Africa/Nairobi, GMT+03:00", + value: "Africa/Nairobi", + }, + { + label: "Africa/Ndjamena, GMT+01:00", + value: "Africa/Ndjamena", + }, + { + label: "Africa/Niamey, GMT+01:00", + value: "Africa/Niamey", + }, + { + label: "Africa/Nouakchott, GMT", + value: "Africa/Nouakchott", + }, + { + label: "Africa/Ouagadougou, GMT", + value: "Africa/Ouagadougou", + }, + { + label: "Africa/Porto-Novo, GMT+01:00", + value: "Africa/Porto-Novo", + }, + { + label: "Africa/Sao_Tome, GMT", + value: "Africa/Sao_Tome", + }, + { + label: "Africa/Timbuktu, GMT", + value: "Africa/Timbuktu", + }, + { + label: "Africa/Tripoli, GMT+02:00", + value: "Africa/Tripoli", + }, + { + label: "Africa/Tunis, GMT+01:00", + value: "Africa/Tunis", + }, + { + label: "Africa/Windhoek, GMT+02:00", + value: "Africa/Windhoek", + }, + { + label: "America/Adak, GMT-09:00", + value: "America/Adak", + }, + { + label: "America/Anchorage, GMT-08:00", + value: "America/Anchorage", + }, + { + label: "America/Anguilla, GMT-04:00", + value: "America/Anguilla", + }, + { + label: "America/Antigua, GMT-04:00", + value: "America/Antigua", + }, + { + label: "America/Araguaina, GMT-03:00", + value: "America/Araguaina", + }, + { + label: "America/Argentina/Buenos_Aires, GMT-03:00", + value: "America/Argentina/Buenos_Aires", + }, + { + label: "America/Argentina/Catamarca, GMT-03:00", + value: "America/Argentina/Catamarca", + }, + { + label: "America/Argentina/ComodRivadavia, GMT-03:00", + value: "America/Argentina/ComodRivadavia", + }, + { + label: "America/Argentina/Cordoba, GMT-03:00", + value: "America/Argentina/Cordoba", + }, + { + label: "America/Argentina/Jujuy, GMT-03:00", + value: "America/Argentina/Jujuy", + }, + { + label: "America/Argentina/La_Rioja, GMT-03:00", + value: "America/Argentina/La_Rioja", + }, + { + label: "America/Argentina/Mendoza, GMT-03:00", + value: "America/Argentina/Mendoza", + }, + { + label: "America/Argentina/Rio_Gallegos, GMT-03:00", + value: "America/Argentina/Rio_Gallegos", + }, + { + label: "America/Argentina/Salta, GMT-03:00", + value: "America/Argentina/Salta", + }, + { + label: "America/Argentina/San_Juan, GMT-03:00", + value: "America/Argentina/San_Juan", + }, + { + label: "America/Argentina/San_Luis, GMT-03:00", + value: "America/Argentina/San_Luis", + }, + { + label: "America/Argentina/Tucuman, GMT-03:00", + value: "America/Argentina/Tucuman", + }, + { + label: "America/Argentina/Ushuaia, GMT-03:00", + value: "America/Argentina/Ushuaia", + }, + { + label: "America/Aruba, GMT-04:00", + value: "America/Aruba", + }, + { + label: "America/Asuncion, GMT-04:00", + value: "America/Asuncion", + }, + { + label: "America/Atikokan, GMT-05:00", + value: "America/Atikokan", + }, + { + label: "America/Atka, GMT-09:00", + value: "America/Atka", + }, + { + label: "America/Bahia, GMT-03:00", + value: "America/Bahia", + }, + { + label: "America/Bahia_Banderas, GMT-06:00", + value: "America/Bahia_Banderas", + }, + { + label: "America/Barbados, GMT-04:00", + value: "America/Barbados", + }, + { + label: "America/Belem, GMT-03:00", + value: "America/Belem", + }, + { + label: "America/Belize, GMT-06:00", + value: "America/Belize", + }, + { + label: "America/Blanc-Sablon, GMT-04:00", + value: "America/Blanc-Sablon", + }, + { + label: "America/Boa_Vista, GMT-04:00", + value: "America/Boa_Vista", + }, + { + label: "America/Bogota, GMT-05:00", + value: "America/Bogota", + }, + { + label: "America/Boise, GMT-06:00", + value: "America/Boise", + }, + { + label: "America/Buenos_Aires, GMT-03:00", + value: "America/Buenos_Aires", + }, + { + label: "America/Cambridge_Bay, GMT-06:00", + value: "America/Cambridge_Bay", + }, + { + label: "America/Campo_Grande, GMT-04:00", + value: "America/Campo_Grande", + }, + { + label: "America/Cancun, GMT-05:00", + value: "America/Cancun", + }, + { + label: "America/Caracas, GMT-04:00", + value: "America/Caracas", + }, + { + label: "America/Catamarca, GMT-03:00", + value: "America/Catamarca", + }, + { + label: "America/Cayenne, GMT-03:00", + value: "America/Cayenne", + }, + { + label: "America/Cayman, GMT-05:00", + value: "America/Cayman", + }, + { + label: "America/Chicago, GMT-05:00", + value: "America/Chicago", + }, + { + label: "America/Chihuahua, GMT-06:00", + value: "America/Chihuahua", + }, + { + label: "America/Ciudad_Juarez, GMT-06:00", + value: "America/Ciudad_Juarez", + }, + { + label: "America/Coral_Harbour, GMT-05:00", + value: "America/Coral_Harbour", + }, + { + label: "America/Cordoba, GMT-03:00", + value: "America/Cordoba", + }, + { + label: "America/Costa_Rica, GMT-06:00", + value: "America/Costa_Rica", + }, + { + label: "America/Creston, GMT-07:00", + value: "America/Creston", + }, + { + label: "America/Cuiaba, GMT-04:00", + value: "America/Cuiaba", + }, + { + label: "America/Curacao, GMT-04:00", + value: "America/Curacao", + }, + { + label: "America/Danmarkshavn, GMT", + value: "America/Danmarkshavn", + }, + { + label: "America/Dawson, GMT-07:00", + value: "America/Dawson", + }, + { + label: "America/Dawson_Creek, GMT-07:00", + value: "America/Dawson_Creek", + }, + { + label: "America/Denver, GMT-06:00", + value: "America/Denver", + }, + { + label: "America/Detroit, GMT-04:00", + value: "America/Detroit", + }, + { + label: "America/Dominica, GMT-04:00", + value: "America/Dominica", + }, + { + label: "America/Edmonton, GMT-06:00", + value: "America/Edmonton", + }, + { + label: "America/Eirunepe, GMT-05:00", + value: "America/Eirunepe", + }, + { + label: "America/El_Salvador, GMT-06:00", + value: "America/El_Salvador", + }, + { + label: "America/Ensenada, GMT-07:00", + value: "America/Ensenada", + }, + { + label: "America/Fort_Nelson, GMT-07:00", + value: "America/Fort_Nelson", + }, + { + label: "America/Fort_Wayne, GMT-04:00", + value: "America/Fort_Wayne", + }, + { + label: "America/Fortaleza, GMT-03:00", + value: "America/Fortaleza", + }, + { + label: "America/Glace_Bay, GMT-03:00", + value: "America/Glace_Bay", + }, + { + label: "America/Godthab, GMT-02:00", + value: "America/Godthab", + }, + { + label: "America/Goose_Bay, GMT-03:00", + value: "America/Goose_Bay", + }, + { + label: "America/Grand_Turk, GMT-04:00", + value: "America/Grand_Turk", + }, + { + label: "America/Grenada, GMT-04:00", + value: "America/Grenada", + }, + { + label: "America/Guadeloupe, GMT-04:00", + value: "America/Guadeloupe", + }, + { + label: "America/Guatemala, GMT-06:00", + value: "America/Guatemala", + }, + { + label: "America/Guayaquil, GMT-05:00", + value: "America/Guayaquil", + }, + { + label: "America/Guyana, GMT-04:00", + value: "America/Guyana", + }, + { + label: "America/Halifax, GMT-03:00", + value: "America/Halifax", + }, + { + label: "America/Havana, GMT-04:00", + value: "America/Havana", + }, + { + label: "America/Hermosillo, GMT-07:00", + value: "America/Hermosillo", + }, + { + label: "America/Indiana/Indianapolis, GMT-04:00", + value: "America/Indiana/Indianapolis", + }, + { + label: "America/Indiana/Knox, GMT-05:00", + value: "America/Indiana/Knox", + }, + { + label: "America/Indiana/Marengo, GMT-04:00", + value: "America/Indiana/Marengo", + }, + { + label: "America/Indiana/Petersburg, GMT-04:00", + value: "America/Indiana/Petersburg", + }, + { + label: "America/Indiana/Tell_City, GMT-05:00", + value: "America/Indiana/Tell_City", + }, + { + label: "America/Indiana/Vevay, GMT-04:00", + value: "America/Indiana/Vevay", + }, + { + label: "America/Indiana/Vincennes, GMT-04:00", + value: "America/Indiana/Vincennes", + }, + { + label: "America/Indiana/Winamac, GMT-04:00", + value: "America/Indiana/Winamac", + }, + { + label: "America/Indianapolis, GMT-04:00", + value: "America/Indianapolis", + }, + { + label: "America/Inuvik, GMT-06:00", + value: "America/Inuvik", + }, + { + label: "America/Iqaluit, GMT-04:00", + value: "America/Iqaluit", + }, + { + label: "America/Jamaica, GMT-05:00", + value: "America/Jamaica", + }, + { + label: "America/Jujuy, GMT-03:00", + value: "America/Jujuy", + }, + { + label: "America/Juneau, GMT-08:00", + value: "America/Juneau", + }, + { + label: "America/Kentucky/Louisville, GMT-04:00", + value: "America/Kentucky/Louisville", + }, + { + label: "America/Kentucky/Monticello, GMT-04:00", + value: "America/Kentucky/Monticello", + }, + { + label: "America/Knox_IN, GMT-05:00", + value: "America/Knox_IN", + }, + { + label: "America/Kralendijk, GMT-04:00", + value: "America/Kralendijk", + }, + { + label: "America/La_Paz, GMT-04:00", + value: "America/La_Paz", + }, + { + label: "America/Lima, GMT-05:00", + value: "America/Lima", + }, + { + label: "America/Los_Angeles, GMT-07:00", + value: "America/Los_Angeles", + }, + { + label: "America/Louisville, GMT-04:00", + value: "America/Louisville", + }, + { + label: "America/Lower_Princes, GMT-04:00", + value: "America/Lower_Princes", + }, + { + label: "America/Maceio, GMT-03:00", + value: "America/Maceio", + }, + { + label: "America/Managua, GMT-06:00", + value: "America/Managua", + }, + { + label: "America/Manaus, GMT-04:00", + value: "America/Manaus", + }, + { + label: "America/Marigot, GMT-04:00", + value: "America/Marigot", + }, + { + label: "America/Martinique, GMT-04:00", + value: "America/Martinique", + }, + { + label: "America/Matamoros, GMT-05:00", + value: "America/Matamoros", + }, + { + label: "America/Mazatlan, GMT-07:00", + value: "America/Mazatlan", + }, + { + label: "America/Mendoza, GMT-03:00", + value: "America/Mendoza", + }, + { + label: "America/Menominee, GMT-05:00", + value: "America/Menominee", + }, + { + label: "America/Merida, GMT-06:00", + value: "America/Merida", + }, + { + label: "America/Metlakatla, GMT-08:00", + value: "America/Metlakatla", + }, + { + label: "America/Mexico_City, GMT-06:00", + value: "America/Mexico_City", + }, + { + label: "America/Miquelon, GMT-02:00", + value: "America/Miquelon", + }, + { + label: "America/Moncton, GMT-03:00", + value: "America/Moncton", + }, + { + label: "America/Monterrey, GMT-06:00", + value: "America/Monterrey", + }, + { + label: "America/Montevideo, GMT-03:00", + value: "America/Montevideo", + }, + { + label: "America/Montreal, GMT-04:00", + value: "America/Montreal", + }, + { + label: "America/Montserrat, GMT-04:00", + value: "America/Montserrat", + }, + { + label: "America/Nassau, GMT-04:00", + value: "America/Nassau", + }, + { + label: "America/New_York, GMT-04:00", + value: "America/New_York", + }, + { + label: "America/Nipigon, GMT-04:00", + value: "America/Nipigon", + }, + { + label: "America/Nome, GMT-08:00", + value: "America/Nome", + }, + { + label: "America/Noronha, GMT-02:00", + value: "America/Noronha", + }, + { + label: "America/North_Dakota/Beulah, GMT-05:00", + value: "America/North_Dakota/Beulah", + }, + { + label: "America/North_Dakota/Center, GMT-05:00", + value: "America/North_Dakota/Center", + }, + { + label: "America/North_Dakota/New_Salem, GMT-05:00", + value: "America/North_Dakota/New_Salem", + }, + { + label: "America/Nuuk, GMT-02:00", + value: "America/Nuuk", + }, + { + label: "America/Ojinaga, GMT-05:00", + value: "America/Ojinaga", + }, + { + label: "America/Panama, GMT-05:00", + value: "America/Panama", + }, + { + label: "America/Pangnirtung, GMT-04:00", + value: "America/Pangnirtung", + }, + { + label: "America/Paramaribo, GMT-03:00", + value: "America/Paramaribo", + }, + { + label: "America/Phoenix, GMT-07:00", + value: "America/Phoenix", + }, + { + label: "America/Port-au-Prince, GMT-04:00", + value: "America/Port-au-Prince", + }, + { + label: "America/Port_of_Spain, GMT-04:00", + value: "America/Port_of_Spain", + }, + { + label: "America/Porto_Acre, GMT-05:00", + value: "America/Porto_Acre", + }, + { + label: "America/Porto_Velho, GMT-04:00", + value: "America/Porto_Velho", + }, + { + label: "America/Puerto_Rico, GMT-04:00", + value: "America/Puerto_Rico", + }, + { + label: "America/Punta_Arenas, GMT-03:00", + value: "America/Punta_Arenas", + }, + { + label: "America/Rainy_River, GMT-05:00", + value: "America/Rainy_River", + }, + { + label: "America/Rankin_Inlet, GMT-05:00", + value: "America/Rankin_Inlet", + }, + { + label: "America/Recife, GMT-03:00", + value: "America/Recife", + }, + { + label: "America/Regina, GMT-06:00", + value: "America/Regina", + }, + { + label: "America/Resolute, GMT-05:00", + value: "America/Resolute", + }, + { + label: "America/Rio_Branco, GMT-05:00", + value: "America/Rio_Branco", + }, + { + label: "America/Rosario, GMT-03:00", + value: "America/Rosario", + }, + { + label: "America/Santa_Isabel, GMT-07:00", + value: "America/Santa_Isabel", + }, + { + label: "America/Santarem, GMT-03:00", + value: "America/Santarem", + }, + { + label: "America/Santiago, GMT-04:00", + value: "America/Santiago", + }, + { + label: "America/Santo_Domingo, GMT-04:00", + value: "America/Santo_Domingo", + }, + { + label: "America/Sao_Paulo, GMT-03:00", + value: "America/Sao_Paulo", + }, + { + label: "America/Scoresbysund, GMT", + value: "America/Scoresbysund", + }, + { + label: "America/Shiprock, GMT-06:00", + value: "America/Shiprock", + }, + { + label: "America/Sitka, GMT-08:00", + value: "America/Sitka", + }, + { + label: "America/St_Barthelemy, GMT-04:00", + value: "America/St_Barthelemy", + }, + { + label: "America/St_Johns, GMT-02:30", + value: "America/St_Johns", + }, + { + label: "America/St_Kitts, GMT-04:00", + value: "America/St_Kitts", + }, + { + label: "America/St_Lucia, GMT-04:00", + value: "America/St_Lucia", + }, + { + label: "America/St_Thomas, GMT-04:00", + value: "America/St_Thomas", + }, + { + label: "America/St_Vincent, GMT-04:00", + value: "America/St_Vincent", + }, + { + label: "America/Swift_Current, GMT-06:00", + value: "America/Swift_Current", + }, + { + label: "America/Tegucigalpa, GMT-06:00", + value: "America/Tegucigalpa", + }, + { + label: "America/Thule, GMT-03:00", + value: "America/Thule", + }, + { + label: "America/Thunder_Bay, GMT-04:00", + value: "America/Thunder_Bay", + }, + { + label: "America/Tijuana, GMT-07:00", + value: "America/Tijuana", + }, + { + label: "America/Toronto, GMT-04:00", + value: "America/Toronto", + }, + { + label: "America/Tortola, GMT-04:00", + value: "America/Tortola", + }, + { + label: "America/Vancouver, GMT-07:00", + value: "America/Vancouver", + }, + { + label: "America/Virgin, GMT-04:00", + value: "America/Virgin", + }, + { + label: "America/Whitehorse, GMT-07:00", + value: "America/Whitehorse", + }, + { + label: "America/Winnipeg, GMT-05:00", + value: "America/Winnipeg", + }, + { + label: "America/Yakutat, GMT-08:00", + value: "America/Yakutat", + }, + { + label: "America/Yellowknife, GMT-06:00", + value: "America/Yellowknife", + }, + { + label: "Antarctica/Casey, GMT+11:00", + value: "Antarctica/Casey", + }, + { + label: "Antarctica/Davis, GMT+07:00", + value: "Antarctica/Davis", + }, + { + label: "Antarctica/DumontDUrville, GMT+10:00", + value: "Antarctica/DumontDUrville", + }, + { + label: "Antarctica/Macquarie, GMT+10:00", + value: "Antarctica/Macquarie", + }, + { + label: "Antarctica/Mawson, GMT+05:00", + value: "Antarctica/Mawson", + }, + { + label: "Antarctica/McMurdo, GMT+12:00", + value: "Antarctica/McMurdo", + }, + { + label: "Antarctica/Palmer, GMT-03:00", + value: "Antarctica/Palmer", + }, + { + label: "Antarctica/Rothera, GMT-03:00", + value: "Antarctica/Rothera", + }, + { + label: "Antarctica/South_Pole, GMT+12:00", + value: "Antarctica/South_Pole", + }, + { + label: "Antarctica/Syowa, GMT+03:00", + value: "Antarctica/Syowa", + }, + { + label: "Antarctica/Troll, GMT+02:00", + value: "Antarctica/Troll", + }, + { + label: "Antarctica/Vostok, GMT+06:00", + value: "Antarctica/Vostok", + }, + { + label: "Arctic/Longyearbyen, GMT+02:00", + value: "Arctic/Longyearbyen", + }, + { + label: "Asia/Aden, GMT+03:00", + value: "Asia/Aden", + }, + { + label: "Asia/Almaty, GMT+06:00", + value: "Asia/Almaty", + }, + { + label: "Asia/Amman, GMT+03:00", + value: "Asia/Amman", + }, + { + label: "Asia/Anadyr, GMT+12:00", + value: "Asia/Anadyr", + }, + { + label: "Asia/Aqtau, GMT+05:00", + value: "Asia/Aqtau", + }, + { + label: "Asia/Aqtobe, GMT+05:00", + value: "Asia/Aqtobe", + }, + { + label: "Asia/Ashgabat, GMT+05:00", + value: "Asia/Ashgabat", + }, + { + label: "Asia/Ashkhabad, GMT+05:00", + value: "Asia/Ashkhabad", + }, + { + label: "Asia/Atyrau, GMT+05:00", + value: "Asia/Atyrau", + }, + { + label: "Asia/Baghdad, GMT+03:00", + value: "Asia/Baghdad", + }, + { + label: "Asia/Bahrain, GMT+03:00", + value: "Asia/Bahrain", + }, + { + label: "Asia/Baku, GMT+04:00", + value: "Asia/Baku", + }, + { + label: "Asia/Bangkok, GMT+07:00", + value: "Asia/Bangkok", + }, + { + label: "Asia/Barnaul, GMT+07:00", + value: "Asia/Barnaul", + }, + { + label: "Asia/Beirut, GMT+03:00", + value: "Asia/Beirut", + }, + { + label: "Asia/Bishkek, GMT+06:00", + value: "Asia/Bishkek", + }, + { + label: "Asia/Brunei, GMT+08:00", + value: "Asia/Brunei", + }, + { + label: "Asia/Calcutta, GMT+05:30", + value: "Asia/Calcutta", + }, + { + label: "Asia/Chita, GMT+09:00", + value: "Asia/Chita", + }, + { + label: "Asia/Choibalsan, GMT+08:00", + value: "Asia/Choibalsan", + }, + { + label: "Asia/Chongqing, GMT+08:00", + value: "Asia/Chongqing", + }, + { + label: "Asia/Chungking, GMT+08:00", + value: "Asia/Chungking", + }, + { + label: "Asia/Colombo, GMT+05:30", + value: "Asia/Colombo", + }, + { + label: "Asia/Dacca, GMT+06:00", + value: "Asia/Dacca", + }, + { + label: "Asia/Damascus, GMT+03:00", + value: "Asia/Damascus", + }, + { + label: "Asia/Dhaka, GMT+06:00", + value: "Asia/Dhaka", + }, + { + label: "Asia/Dili, GMT+09:00", + value: "Asia/Dili", + }, + { + label: "Asia/Dubai, GMT+04:00", + value: "Asia/Dubai", + }, + { + label: "Asia/Dushanbe, GMT+05:00", + value: "Asia/Dushanbe", + }, + { + label: "Asia/Famagusta, GMT+03:00", + value: "Asia/Famagusta", + }, + { + label: "Asia/Gaza, GMT+03:00", + value: "Asia/Gaza", + }, + { + label: "Asia/Harbin, GMT+08:00", + value: "Asia/Harbin", + }, + { + label: "Asia/Hebron, GMT+03:00", + value: "Asia/Hebron", + }, + { + label: "Asia/Ho_Chi_Minh, GMT+07:00", + value: "Asia/Ho_Chi_Minh", + }, + { + label: "Asia/Hong_Kong, GMT+08:00", + value: "Asia/Hong_Kong", + }, + { + label: "Asia/Hovd, GMT+07:00", + value: "Asia/Hovd", + }, + { + label: "Asia/Irkutsk, GMT+08:00", + value: "Asia/Irkutsk", + }, + { + label: "Asia/Istanbul, GMT+03:00", + value: "Asia/Istanbul", + }, + { + label: "Asia/Jakarta, GMT+07:00", + value: "Asia/Jakarta", + }, + { + label: "Asia/Jayapura, GMT+09:00", + value: "Asia/Jayapura", + }, + { + label: "Asia/Jerusalem, GMT+03:00", + value: "Asia/Jerusalem", + }, + { + label: "Asia/Kabul, GMT+04:30", + value: "Asia/Kabul", + }, + { + label: "Asia/Kamchatka, GMT+12:00", + value: "Asia/Kamchatka", + }, + { + label: "Asia/Karachi, GMT+05:00", + value: "Asia/Karachi", + }, + { + label: "Asia/Kashgar, GMT+06:00", + value: "Asia/Kashgar", + }, + { + label: "Asia/Kathmandu, GMT+05:45", + value: "Asia/Kathmandu", + }, + { + label: "Asia/Katmandu, GMT+05:45", + value: "Asia/Katmandu", + }, + { + label: "Asia/Khandyga, GMT+09:00", + value: "Asia/Khandyga", + }, + { + label: "Asia/Kolkata, GMT+05:30", + value: "Asia/Kolkata", + }, + { + label: "Asia/Krasnoyarsk, GMT+07:00", + value: "Asia/Krasnoyarsk", + }, + { + label: "Asia/Kuala_Lumpur, GMT+08:00", + value: "Asia/Kuala_Lumpur", + }, + { + label: "Asia/Kuching, GMT+08:00", + value: "Asia/Kuching", + }, + { + label: "Asia/Kuwait, GMT+03:00", + value: "Asia/Kuwait", + }, + { + label: "Asia/Macao, GMT+08:00", + value: "Asia/Macao", + }, + { + label: "Asia/Macau, GMT+08:00", + value: "Asia/Macau", + }, + { + label: "Asia/Magadan, GMT+11:00", + value: "Asia/Magadan", + }, + { + label: "Asia/Makassar, GMT+08:00", + value: "Asia/Makassar", + }, + { + label: "Asia/Manila, GMT+08:00", + value: "Asia/Manila", + }, + { + label: "Asia/Muscat, GMT+04:00", + value: "Asia/Muscat", + }, + { + label: "Asia/Nicosia, GMT+03:00", + value: "Asia/Nicosia", + }, + { + label: "Asia/Novokuznetsk, GMT+07:00", + value: "Asia/Novokuznetsk", + }, + { + label: "Asia/Novosibirsk, GMT+07:00", + value: "Asia/Novosibirsk", + }, + { + label: "Asia/Omsk, GMT+06:00", + value: "Asia/Omsk", + }, + { + label: "Asia/Oral, GMT+05:00", + value: "Asia/Oral", + }, + { + label: "Asia/Phnom_Penh, GMT+07:00", + value: "Asia/Phnom_Penh", + }, + { + label: "Asia/Pontianak, GMT+07:00", + value: "Asia/Pontianak", + }, + { + label: "Asia/Pyongyang, GMT+09:00", + value: "Asia/Pyongyang", + }, + { + label: "Asia/Qatar, GMT+03:00", + value: "Asia/Qatar", + }, + { + label: "Asia/Qostanay, GMT+06:00", + value: "Asia/Qostanay", + }, + { + label: "Asia/Qyzylorda, GMT+05:00", + value: "Asia/Qyzylorda", + }, + { + label: "Asia/Rangoon, GMT+06:30", + value: "Asia/Rangoon", + }, + { + label: "Asia/Riyadh, GMT+03:00", + value: "Asia/Riyadh", + }, + { + label: "Asia/Saigon, GMT+07:00", + value: "Asia/Saigon", + }, + { + label: "Asia/Sakhalin, GMT+11:00", + value: "Asia/Sakhalin", + }, + { + label: "Asia/Samarkand, GMT+05:00", + value: "Asia/Samarkand", + }, + { + label: "Asia/Seoul, GMT+09:00", + value: "Asia/Seoul", + }, + { + label: "Asia/Shanghai, GMT+08:00", + value: "Asia/Shanghai", + }, + { + label: "Asia/Singapore, GMT+08:00", + value: "Asia/Singapore", + }, + { + label: "Asia/Srednekolymsk, GMT+11:00", + value: "Asia/Srednekolymsk", + }, + { + label: "Asia/Taipei, GMT+08:00", + value: "Asia/Taipei", + }, + { + label: "Asia/Tashkent, GMT+05:00", + value: "Asia/Tashkent", + }, + { + label: "Asia/Tbilisi, GMT+04:00", + value: "Asia/Tbilisi", + }, + { + label: "Asia/Tehran, GMT+03:30", + value: "Asia/Tehran", + }, + { + label: "Asia/Tel_Aviv, GMT+03:00", + value: "Asia/Tel_Aviv", + }, + { + label: "Asia/Thimbu, GMT+06:00", + value: "Asia/Thimbu", + }, + { + label: "Asia/Thimphu, GMT+06:00", + value: "Asia/Thimphu", + }, + { + label: "Asia/Tokyo, GMT+09:00", + value: "Asia/Tokyo", + }, + { + label: "Asia/Tomsk, GMT+07:00", + value: "Asia/Tomsk", + }, + { + label: "Asia/Ujung_Pandang, GMT+08:00", + value: "Asia/Ujung_Pandang", + }, + { + label: "Asia/Ulaanbaatar, GMT+08:00", + value: "Asia/Ulaanbaatar", + }, + { + label: "Asia/Ulan_Bator, GMT+08:00", + value: "Asia/Ulan_Bator", + }, + { + label: "Asia/Urumqi, GMT+06:00", + value: "Asia/Urumqi", + }, + { + label: "Asia/Ust-Nera, GMT+10:00", + value: "Asia/Ust-Nera", + }, + { + label: "Asia/Vientiane, GMT+07:00", + value: "Asia/Vientiane", + }, + { + label: "Asia/Vladivostok, GMT+10:00", + value: "Asia/Vladivostok", + }, + { + label: "Asia/Yakutsk, GMT+09:00", + value: "Asia/Yakutsk", + }, + { + label: "Asia/Yangon, GMT+06:30", + value: "Asia/Yangon", + }, + { + label: "Asia/Yekaterinburg, GMT+05:00", + value: "Asia/Yekaterinburg", + }, + { + label: "Asia/Yerevan, GMT+04:00", + value: "Asia/Yerevan", + }, + { + label: "Atlantic/Azores, GMT", + value: "Atlantic/Azores", + }, + { + label: "Atlantic/Bermuda, GMT-03:00", + value: "Atlantic/Bermuda", + }, + { + label: "Atlantic/Canary, GMT+01:00", + value: "Atlantic/Canary", + }, + { + label: "Atlantic/Cape_Verde, GMT-01:00", + value: "Atlantic/Cape_Verde", + }, + { + label: "Atlantic/Faeroe, GMT+01:00", + value: "Atlantic/Faeroe", + }, + { + label: "Atlantic/Faroe, GMT+01:00", + value: "Atlantic/Faroe", + }, + { + label: "Atlantic/Jan_Mayen, GMT+02:00", + value: "Atlantic/Jan_Mayen", + }, + { + label: "Atlantic/Madeira, GMT+01:00", + value: "Atlantic/Madeira", + }, + { + label: "Atlantic/Reykjavik, GMT", + value: "Atlantic/Reykjavik", + }, + { + label: "Atlantic/South_Georgia, GMT-02:00", + value: "Atlantic/South_Georgia", + }, + { + label: "Atlantic/St_Helena, GMT", + value: "Atlantic/St_Helena", + }, + { + label: "Atlantic/Stanley, GMT-03:00", + value: "Atlantic/Stanley", + }, + { + label: "Australia/ACT, GMT+10:00", + value: "Australia/ACT", + }, + { + label: "Australia/Adelaide, GMT+09:30", + value: "Australia/Adelaide", + }, + { + label: "Australia/Brisbane, GMT+10:00", + value: "Australia/Brisbane", + }, + { + label: "Australia/Broken_Hill, GMT+09:30", + value: "Australia/Broken_Hill", + }, + { + label: "Australia/Canberra, GMT+10:00", + value: "Australia/Canberra", + }, + { + label: "Australia/Currie, GMT+10:00", + value: "Australia/Currie", + }, + { + label: "Australia/Darwin, GMT+09:30", + value: "Australia/Darwin", + }, + { + label: "Australia/Eucla, GMT+08:45", + value: "Australia/Eucla", + }, + { + label: "Australia/Hobart, GMT+10:00", + value: "Australia/Hobart", + }, + { + label: "Australia/LHI, GMT+10:30", + value: "Australia/LHI", + }, + { + label: "Australia/Lindeman, GMT+10:00", + value: "Australia/Lindeman", + }, + { + label: "Australia/Lord_Howe, GMT+10:30", + value: "Australia/Lord_Howe", + }, + { + label: "Australia/Melbourne, GMT+10:00", + value: "Australia/Melbourne", + }, + { + label: "Australia/NSW, GMT+10:00", + value: "Australia/NSW", + }, + { + label: "Australia/North, GMT+09:30", + value: "Australia/North", + }, + { + label: "Australia/Perth, GMT+08:00", + value: "Australia/Perth", + }, + { + label: "Australia/Queensland, GMT+10:00", + value: "Australia/Queensland", + }, + { + label: "Australia/South, GMT+09:30", + value: "Australia/South", + }, + { + label: "Australia/Sydney, GMT+10:00", + value: "Australia/Sydney", + }, + { + label: "Australia/Tasmania, GMT+10:00", + value: "Australia/Tasmania", + }, + { + label: "Australia/Victoria, GMT+10:00", + value: "Australia/Victoria", + }, + { + label: "Australia/West, GMT+08:00", + value: "Australia/West", + }, + { + label: "Australia/Yancowinna, GMT+09:30", + value: "Australia/Yancowinna", + }, + { + label: "Brazil/Acre, GMT-05:00", + value: "Brazil/Acre", + }, + { + label: "Brazil/DeNoronha, GMT-02:00", + value: "Brazil/DeNoronha", + }, + { + label: "Brazil/East, GMT-03:00", + value: "Brazil/East", + }, + { + label: "Brazil/West, GMT-04:00", + value: "Brazil/West", + }, + { + label: "CET, GMT+02:00", + value: "CET", + }, + { + label: "CST6CDT, GMT-05:00", + value: "CST6CDT", + }, + { + label: "Canada/Atlantic, GMT-03:00", + value: "Canada/Atlantic", + }, + { + label: "Canada/Central, GMT-05:00", + value: "Canada/Central", + }, + { + label: "Canada/Eastern, GMT-04:00", + value: "Canada/Eastern", + }, + { + label: "Canada/Mountain, GMT-06:00", + value: "Canada/Mountain", + }, + { + label: "Canada/Newfoundland, GMT-02:30", + value: "Canada/Newfoundland", + }, + { + label: "Canada/Pacific, GMT-07:00", + value: "Canada/Pacific", + }, + { + label: "Canada/Saskatchewan, GMT-06:00", + value: "Canada/Saskatchewan", + }, + { + label: "Canada/Yukon, GMT-07:00", + value: "Canada/Yukon", + }, + { + label: "Chile/Continental, GMT-04:00", + value: "Chile/Continental", + }, + { + label: "Chile/EasterIsland, GMT-06:00", + value: "Chile/EasterIsland", + }, + { + label: "Cuba, GMT-04:00", + value: "Cuba", + }, + { + label: "EET, GMT+03:00", + value: "EET", + }, + { + label: "EST, GMT-05:00", + value: "EST", + }, + { + label: "EST5EDT, GMT-04:00", + value: "EST5EDT", + }, + { + label: "Egypt, GMT+03:00", + value: "Egypt", + }, + { + label: "Eire, GMT+01:00", + value: "Eire", + }, + { + label: "Etc/GMT, GMT", + value: "Etc/GMT", + }, + { + label: "Etc/GMT+0, GMT", + value: "Etc/GMT+0", + }, + { + label: "Etc/GMT+1, GMT-01:00", + value: "Etc/GMT+1", + }, + { + label: "Etc/GMT+10, GMT-10:00", + value: "Etc/GMT+10", + }, + { + label: "Etc/GMT+11, GMT-11:00", + value: "Etc/GMT+11", + }, + { + label: "Etc/GMT+12, GMT-12:00", + value: "Etc/GMT+12", + }, + { + label: "Etc/GMT+2, GMT-02:00", + value: "Etc/GMT+2", + }, + { + label: "Etc/GMT+3, GMT-03:00", + value: "Etc/GMT+3", + }, + { + label: "Etc/GMT+4, GMT-04:00", + value: "Etc/GMT+4", + }, + { + label: "Etc/GMT+5, GMT-05:00", + value: "Etc/GMT+5", + }, + { + label: "Etc/GMT+6, GMT-06:00", + value: "Etc/GMT+6", + }, + { + label: "Etc/GMT+7, GMT-07:00", + value: "Etc/GMT+7", + }, + { + label: "Etc/GMT+8, GMT-08:00", + value: "Etc/GMT+8", + }, + { + label: "Etc/GMT+9, GMT-09:00", + value: "Etc/GMT+9", + }, + { + label: "Etc/GMT-0, GMT", + value: "Etc/GMT-0", + }, + { + label: "Etc/GMT-1, GMT+01:00", + value: "Etc/GMT-1", + }, + { + label: "Etc/GMT-10, GMT+10:00", + value: "Etc/GMT-10", + }, + { + label: "Etc/GMT-11, GMT+11:00", + value: "Etc/GMT-11", + }, + { + label: "Etc/GMT-12, GMT+12:00", + value: "Etc/GMT-12", + }, + { + label: "Etc/GMT-13, GMT+13:00", + value: "Etc/GMT-13", + }, + { + label: "Etc/GMT-14, GMT+14:00", + value: "Etc/GMT-14", + }, + { + label: "Etc/GMT-2, GMT+02:00", + value: "Etc/GMT-2", + }, + { + label: "Etc/GMT-3, GMT+03:00", + value: "Etc/GMT-3", + }, + { + label: "Etc/GMT-4, GMT+04:00", + value: "Etc/GMT-4", + }, + { + label: "Etc/GMT-5, GMT+05:00", + value: "Etc/GMT-5", + }, + { + label: "Etc/GMT-6, GMT+06:00", + value: "Etc/GMT-6", + }, + { + label: "Etc/GMT-7, GMT+07:00", + value: "Etc/GMT-7", + }, + { + label: "Etc/GMT-8, GMT+08:00", + value: "Etc/GMT-8", + }, + { + label: "Etc/GMT-9, GMT+09:00", + value: "Etc/GMT-9", + }, + { + label: "Etc/GMT0, GMT", + value: "Etc/GMT0", + }, + { + label: "Etc/Greenwich, GMT", + value: "Etc/Greenwich", + }, + { + label: "Etc/UCT, GMT", + value: "Etc/UCT", + }, + { + label: "Etc/UTC, GMT", + value: "Etc/UTC", + }, + { + label: "Etc/Universal, GMT", + value: "Etc/Universal", + }, + { + label: "Etc/Zulu, GMT", + value: "Etc/Zulu", + }, + { + label: "Europe/Amsterdam, GMT+02:00", + value: "Europe/Amsterdam", + }, + { + label: "Europe/Andorra, GMT+02:00", + value: "Europe/Andorra", + }, + { + label: "Europe/Astrakhan, GMT+04:00", + value: "Europe/Astrakhan", + }, + { + label: "Europe/Athens, GMT+03:00", + value: "Europe/Athens", + }, + { + label: "Europe/Belfast, GMT+01:00", + value: "Europe/Belfast", + }, + { + label: "Europe/Belgrade, GMT+02:00", + value: "Europe/Belgrade", + }, + { + label: "Europe/Berlin, GMT+02:00", + value: "Europe/Berlin", + }, + { + label: "Europe/Bratislava, GMT+02:00", + value: "Europe/Bratislava", + }, + { + label: "Europe/Brussels, GMT+02:00", + value: "Europe/Brussels", + }, + { + label: "Europe/Bucharest, GMT+03:00", + value: "Europe/Bucharest", + }, + { + label: "Europe/Budapest, GMT+02:00", + value: "Europe/Budapest", + }, + { + label: "Europe/Busingen, GMT+02:00", + value: "Europe/Busingen", + }, + { + label: "Europe/Chisinau, GMT+03:00", + value: "Europe/Chisinau", + }, + { + label: "Europe/Copenhagen, GMT+02:00", + value: "Europe/Copenhagen", + }, + { + label: "Europe/Dublin, GMT+01:00", + value: "Europe/Dublin", + }, + { + label: "Europe/Gibraltar, GMT+02:00", + value: "Europe/Gibraltar", + }, + { + label: "Europe/Guernsey, GMT+01:00", + value: "Europe/Guernsey", + }, + { + label: "Europe/Helsinki, GMT+03:00", + value: "Europe/Helsinki", + }, + { + label: "Europe/Isle_of_Man, GMT+01:00", + value: "Europe/Isle_of_Man", + }, + { + label: "Europe/Istanbul, GMT+03:00", + value: "Europe/Istanbul", + }, + { + label: "Europe/Jersey, GMT+01:00", + value: "Europe/Jersey", + }, + { + label: "Europe/Kaliningrad, GMT+02:00", + value: "Europe/Kaliningrad", + }, + { + label: "Europe/Kiev, GMT+03:00", + value: "Europe/Kiev", + }, + { + label: "Europe/Kirov, GMT+03:00", + value: "Europe/Kirov", + }, + { + label: "Europe/Kyiv, GMT+03:00", + value: "Europe/Kyiv", + }, + { + label: "Europe/Lisbon, GMT+01:00", + value: "Europe/Lisbon", + }, + { + label: "Europe/Ljubljana, GMT+02:00", + value: "Europe/Ljubljana", + }, + { + label: "Europe/London, GMT+01:00", + value: "Europe/London", + }, + { + label: "Europe/Luxembourg, GMT+02:00", + value: "Europe/Luxembourg", + }, + { + label: "Europe/Madrid, GMT+02:00", + value: "Europe/Madrid", + }, + { + label: "Europe/Malta, GMT+02:00", + value: "Europe/Malta", + }, + { + label: "Europe/Mariehamn, GMT+03:00", + value: "Europe/Mariehamn", + }, + { + label: "Europe/Minsk, GMT+03:00", + value: "Europe/Minsk", + }, + { + label: "Europe/Monaco, GMT+02:00", + value: "Europe/Monaco", + }, + { + label: "Europe/Moscow, GMT+03:00", + value: "Europe/Moscow", + }, + { + label: "Europe/Nicosia, GMT+03:00", + value: "Europe/Nicosia", + }, + { + label: "Europe/Oslo, GMT+02:00", + value: "Europe/Oslo", + }, + { + label: "Europe/Paris, GMT+02:00", + value: "Europe/Paris", + }, + { + label: "Europe/Podgorica, GMT+02:00", + value: "Europe/Podgorica", + }, + { + label: "Europe/Prague, GMT+02:00", + value: "Europe/Prague", + }, + { + label: "Europe/Riga, GMT+03:00", + value: "Europe/Riga", + }, + { + label: "Europe/Rome, GMT+02:00", + value: "Europe/Rome", + }, + { + label: "Europe/Samara, GMT+04:00", + value: "Europe/Samara", + }, + { + label: "Europe/San_Marino, GMT+02:00", + value: "Europe/San_Marino", + }, + { + label: "Europe/Sarajevo, GMT+02:00", + value: "Europe/Sarajevo", + }, + { + label: "Europe/Saratov, GMT+04:00", + value: "Europe/Saratov", + }, + { + label: "Europe/Simferopol, GMT+03:00", + value: "Europe/Simferopol", + }, + { + label: "Europe/Skopje, GMT+02:00", + value: "Europe/Skopje", + }, + { + label: "Europe/Sofia, GMT+03:00", + value: "Europe/Sofia", + }, + { + label: "Europe/Stockholm, GMT+02:00", + value: "Europe/Stockholm", + }, + { + label: "Europe/Tallinn, GMT+03:00", + value: "Europe/Tallinn", + }, + { + label: "Europe/Tirane, GMT+02:00", + value: "Europe/Tirane", + }, + { + label: "Europe/Tiraspol, GMT+03:00", + value: "Europe/Tiraspol", + }, + { + label: "Europe/Ulyanovsk, GMT+04:00", + value: "Europe/Ulyanovsk", + }, + { + label: "Europe/Uzhgorod, GMT+03:00", + value: "Europe/Uzhgorod", + }, + { + label: "Europe/Vaduz, GMT+02:00", + value: "Europe/Vaduz", + }, + { + label: "Europe/Vatican, GMT+02:00", + value: "Europe/Vatican", + }, + { + label: "Europe/Vienna, GMT+02:00", + value: "Europe/Vienna", + }, + { + label: "Europe/Vilnius, GMT+03:00", + value: "Europe/Vilnius", + }, + { + label: "Europe/Volgograd, GMT+03:00", + value: "Europe/Volgograd", + }, + { + label: "Europe/Warsaw, GMT+02:00", + value: "Europe/Warsaw", + }, + { + label: "Europe/Zagreb, GMT+02:00", + value: "Europe/Zagreb", + }, + { + label: "Europe/Zaporozhye, GMT+03:00", + value: "Europe/Zaporozhye", + }, + { + label: "Europe/Zurich, GMT+02:00", + value: "Europe/Zurich", + }, + { + label: "GB, GMT+01:00", + value: "GB", + }, + { + label: "GB-Eire, GMT+01:00", + value: "GB-Eire", + }, + { + label: "GMT, GMT", + value: "GMT", + }, + { + label: "GMT+0, GMT", + value: "GMT+0", + }, + { + label: "GMT-0, GMT", + value: "GMT-0", + }, + { + label: "GMT0, GMT", + value: "GMT0", + }, + { + label: "Greenwich, GMT", + value: "Greenwich", + }, + { + label: "HST, GMT-10:00", + value: "HST", + }, + { + label: "Hongkong, GMT+08:00", + value: "Hongkong", + }, + { + label: "Iceland, GMT", + value: "Iceland", + }, + { + label: "Indian/Antananarivo, GMT+03:00", + value: "Indian/Antananarivo", + }, + { + label: "Indian/Chagos, GMT+06:00", + value: "Indian/Chagos", + }, + { + label: "Indian/Christmas, GMT+07:00", + value: "Indian/Christmas", + }, + { + label: "Indian/Cocos, GMT+06:30", + value: "Indian/Cocos", + }, + { + label: "Indian/Comoro, GMT+03:00", + value: "Indian/Comoro", + }, + { + label: "Indian/Kerguelen, GMT+05:00", + value: "Indian/Kerguelen", + }, + { + label: "Indian/Mahe, GMT+04:00", + value: "Indian/Mahe", + }, + { + label: "Indian/Maldives, GMT+05:00", + value: "Indian/Maldives", + }, + { + label: "Indian/Mauritius, GMT+04:00", + value: "Indian/Mauritius", + }, + { + label: "Indian/Mayotte, GMT+03:00", + value: "Indian/Mayotte", + }, + { + label: "Indian/Reunion, GMT+04:00", + value: "Indian/Reunion", + }, + { + label: "Iran, GMT+03:30", + value: "Iran", + }, + { + label: "Israel, GMT+03:00", + value: "Israel", + }, + { + label: "Jamaica, GMT-05:00", + value: "Jamaica", + }, + { + label: "Japan, GMT+09:00", + value: "Japan", + }, + { + label: "Kwajalein, GMT+12:00", + value: "Kwajalein", + }, + { + label: "Libya, GMT+02:00", + value: "Libya", + }, + { + label: "MET, GMT+02:00", + value: "MET", + }, + { + label: "MST, GMT-07:00", + value: "MST", + }, + { + label: "MST7MDT, GMT-06:00", + value: "MST7MDT", + }, + { + label: "Mexico/BajaNorte, GMT-07:00", + value: "Mexico/BajaNorte", + }, + { + label: "Mexico/BajaSur, GMT-07:00", + value: "Mexico/BajaSur", + }, + { + label: "Mexico/General, GMT-06:00", + value: "Mexico/General", + }, + { + label: "NZ, GMT+12:00", + value: "NZ", + }, + { + label: "NZ-CHAT, GMT+12:45", + value: "NZ-CHAT", + }, + { + label: "Navajo, GMT-06:00", + value: "Navajo", + }, + { + label: "PRC, GMT+08:00", + value: "PRC", + }, + { + label: "PST8PDT, GMT-07:00", + value: "PST8PDT", + }, + { + label: "Pacific/Apia, GMT+13:00", + value: "Pacific/Apia", + }, + { + label: "Pacific/Auckland, GMT+12:00", + value: "Pacific/Auckland", + }, + { + label: "Pacific/Bougainville, GMT+11:00", + value: "Pacific/Bougainville", + }, + { + label: "Pacific/Chatham, GMT+12:45", + value: "Pacific/Chatham", + }, + { + label: "Pacific/Chuuk, GMT+10:00", + value: "Pacific/Chuuk", + }, + { + label: "Pacific/Easter, GMT-06:00", + value: "Pacific/Easter", + }, + { + label: "Pacific/Efate, GMT+11:00", + value: "Pacific/Efate", + }, + { + label: "Pacific/Enderbury, GMT+13:00", + value: "Pacific/Enderbury", + }, + { + label: "Pacific/Fakaofo, GMT+13:00", + value: "Pacific/Fakaofo", + }, + { + label: "Pacific/Fiji, GMT+12:00", + value: "Pacific/Fiji", + }, + { + label: "Pacific/Funafuti, GMT+12:00", + value: "Pacific/Funafuti", + }, + { + label: "Pacific/Galapagos, GMT-06:00", + value: "Pacific/Galapagos", + }, + { + label: "Pacific/Gambier, GMT-09:00", + value: "Pacific/Gambier", + }, + { + label: "Pacific/Guadalcanal, GMT+11:00", + value: "Pacific/Guadalcanal", + }, + { + label: "Pacific/Guam, GMT+10:00", + value: "Pacific/Guam", + }, + { + label: "Pacific/Honolulu, GMT-10:00", + value: "Pacific/Honolulu", + }, + { + label: "Pacific/Johnston, GMT-10:00", + value: "Pacific/Johnston", + }, + { + label: "Pacific/Kanton, GMT+13:00", + value: "Pacific/Kanton", + }, + { + label: "Pacific/Kiritimati, GMT+14:00", + value: "Pacific/Kiritimati", + }, + { + label: "Pacific/Kosrae, GMT+11:00", + value: "Pacific/Kosrae", + }, + { + label: "Pacific/Kwajalein, GMT+12:00", + value: "Pacific/Kwajalein", + }, + { + label: "Pacific/Majuro, GMT+12:00", + value: "Pacific/Majuro", + }, + { + label: "Pacific/Marquesas, GMT-09:30", + value: "Pacific/Marquesas", + }, + { + label: "Pacific/Midway, GMT-11:00", + value: "Pacific/Midway", + }, + { + label: "Pacific/Nauru, GMT+12:00", + value: "Pacific/Nauru", + }, + { + label: "Pacific/Niue, GMT-11:00", + value: "Pacific/Niue", + }, + { + label: "Pacific/Norfolk, GMT+11:00", + value: "Pacific/Norfolk", + }, + { + label: "Pacific/Noumea, GMT+11:00", + value: "Pacific/Noumea", + }, + { + label: "Pacific/Pago_Pago, GMT-11:00", + value: "Pacific/Pago_Pago", + }, + { + label: "Pacific/Palau, GMT+09:00", + value: "Pacific/Palau", + }, + { + label: "Pacific/Pitcairn, GMT-08:00", + value: "Pacific/Pitcairn", + }, + { + label: "Pacific/Pohnpei, GMT+11:00", + value: "Pacific/Pohnpei", + }, + { + label: "Pacific/Ponape, GMT+11:00", + value: "Pacific/Ponape", + }, + { + label: "Pacific/Port_Moresby, GMT+10:00", + value: "Pacific/Port_Moresby", + }, + { + label: "Pacific/Rarotonga, GMT-10:00", + value: "Pacific/Rarotonga", + }, + { + label: "Pacific/Saipan, GMT+10:00", + value: "Pacific/Saipan", + }, + { + label: "Pacific/Samoa, GMT-11:00", + value: "Pacific/Samoa", + }, + { + label: "Pacific/Tahiti, GMT-10:00", + value: "Pacific/Tahiti", + }, + { + label: "Pacific/Tarawa, GMT+12:00", + value: "Pacific/Tarawa", + }, + { + label: "Pacific/Tongatapu, GMT+13:00", + value: "Pacific/Tongatapu", + }, + { + label: "Pacific/Truk, GMT+10:00", + value: "Pacific/Truk", + }, + { + label: "Pacific/Wake, GMT+12:00", + value: "Pacific/Wake", + }, + { + label: "Pacific/Wallis, GMT+12:00", + value: "Pacific/Wallis", + }, + { + label: "Pacific/Yap, GMT+10:00", + value: "Pacific/Yap", + }, + { + label: "Poland, GMT+02:00", + value: "Poland", + }, + { + label: "Portugal, GMT+01:00", + value: "Portugal", + }, + { + label: "ROC, GMT+08:00", + value: "ROC", + }, + { + label: "ROK, GMT+09:00", + value: "ROK", + }, + { + label: "Singapore, GMT+08:00", + value: "Singapore", + }, + { + label: "Turkey, GMT+03:00", + value: "Turkey", + }, + { + label: "UCT, GMT", + value: "UCT", + }, + { + label: "US/Alaska, GMT-08:00", + value: "US/Alaska", + }, + { + label: "US/Aleutian, GMT-09:00", + value: "US/Aleutian", + }, + { + label: "US/Arizona, GMT-07:00", + value: "US/Arizona", + }, + { + label: "US/Central, GMT-05:00", + value: "US/Central", + }, + { + label: "US/East-Indiana, GMT-04:00", + value: "US/East-Indiana", + }, + { + label: "US/Eastern, GMT-04:00", + value: "US/Eastern", + }, + { + label: "US/Hawaii, GMT-10:00", + value: "US/Hawaii", + }, + { + label: "US/Indiana-Starke, GMT-05:00", + value: "US/Indiana-Starke", + }, + { + label: "US/Michigan, GMT-04:00", + value: "US/Michigan", + }, + { + label: "US/Mountain, GMT-06:00", + value: "US/Mountain", + }, + { + label: "US/Pacific, GMT-07:00", + value: "US/Pacific", + }, + { + label: "US/Samoa, GMT-11:00", + value: "US/Samoa", + }, + { + label: "UTC, GMT", + value: "UTC", + }, + { + label: "Universal, GMT", + value: "Universal", + }, + { + label: "W-SU, GMT+03:00", + value: "W-SU", + }, + { + label: "WET, GMT+01:00", + value: "WET", + }, + { + label: "Zulu, GMT", + value: "Zulu", + }, +]; diff --git a/apps/app/pages/[workspaceSlug]/me/profile/index.tsx b/apps/app/pages/[workspaceSlug]/me/profile/index.tsx index 4ea92d49d..6c0af1bc2 100644 --- a/apps/app/pages/[workspaceSlug]/me/profile/index.tsx +++ b/apps/app/pages/[workspaceSlug]/me/profile/index.tsx @@ -14,7 +14,14 @@ import SettingsNavbar from "layouts/settings-navbar"; // components import { ImagePickerPopover, ImageUploadModal } from "components/core"; // ui -import { CustomSelect, DangerButton, Input, SecondaryButton, Spinner } from "components/ui"; +import { + CustomSearchSelect, + CustomSelect, + DangerButton, + Input, + SecondaryButton, + Spinner, +} from "components/ui"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; // icons import { UserIcon } from "@heroicons/react/24/outline"; @@ -23,6 +30,7 @@ import type { NextPage } from "next"; import type { IUser } from "types"; // constants import { USER_ROLES } from "constants/workspace"; +import { TIME_ZONES } from "constants/timezones"; const defaultValues: Partial<IUser> = { avatar: "", @@ -31,6 +39,7 @@ const defaultValues: Partial<IUser> = { last_name: "", email: "", role: "Product / Project Manager", + user_timezone: "Asia/Kolkata", }; const Profile: NextPage = () => { @@ -72,6 +81,7 @@ const Profile: NextPage = () => { cover_image: formData.cover_image, role: formData.role, display_name: formData.display_name, + user_timezone: formData.user_timezone, }; await userService @@ -128,6 +138,12 @@ const Profile: NextPage = () => { }); }; + const timeZoneOptions = TIME_ZONES.map((timeZone) => ({ + value: timeZone.value, + query: timeZone.label + " " + timeZone.value, + content: timeZone.label, + })); + return ( <WorkspaceAuthorizationLayout breadcrumbs={ @@ -348,6 +364,35 @@ const Profile: NextPage = () => { {errors.role && <span className="text-xs text-red-500">Please select a role</span>} </div> </div> + <div className="grid grid-cols-12 gap-4 sm:gap-16"> + <div className="col-span-12 sm:col-span-6"> + <h4 className="text-lg font-semibold text-custom-text-100">Timezone</h4> + <p className="text-sm text-custom-text-200">Select a timezone</p> + </div> + <div className="col-span-12 sm:col-span-6"> + <Controller + name="user_timezone" + control={control} + rules={{ required: "This field is required" }} + render={({ field: { value, onChange } }) => ( + <CustomSearchSelect + value={value} + label={ + value + ? TIME_ZONES.find((t) => t.value === value)?.label ?? value + : "Select a timezone" + } + options={timeZoneOptions} + onChange={onChange} + verticalPosition="top" + optionsClassName="w-full" + input + /> + )} + /> + {errors.role && <span className="text-xs text-red-500">Please select a role</span>} + </div> + </div> <div className="sm:text-right"> <SecondaryButton type="submit" loading={isSubmitting}> {isSubmitting ? "Updating..." : "Update profile"} diff --git a/apps/app/types/users.d.ts b/apps/app/types/users.d.ts index b41d1f8ef..55c95e5f3 100644 --- a/apps/app/types/users.d.ts +++ b/apps/app/types/users.d.ts @@ -36,6 +36,7 @@ export interface IUser { theme: ICustomTheme; updated_at: readonly Date; username: string; + user_timezone: string; [...rest: string]: any; } From fd0efb0242403af5c8132f47572496babf719a09 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 29 Aug 2023 20:11:38 +0530 Subject: [PATCH 12/27] fix: start date filter not working on the platform (#2007) --- apps/app/hooks/gantt-chart/cycle-issues-view.tsx | 1 + apps/app/hooks/gantt-chart/issue-view.tsx | 1 + apps/app/hooks/gantt-chart/module-issues-view.tsx | 1 + apps/app/hooks/use-calendar-issues-view.tsx | 1 + apps/app/hooks/use-spreadsheet-issues-view.tsx | 2 ++ 5 files changed, 6 insertions(+) diff --git a/apps/app/hooks/gantt-chart/cycle-issues-view.tsx b/apps/app/hooks/gantt-chart/cycle-issues-view.tsx index 7ef534fb4..8d32d5e4b 100644 --- a/apps/app/hooks/gantt-chart/cycle-issues-view.tsx +++ b/apps/app/hooks/gantt-chart/cycle-issues-view.tsx @@ -23,6 +23,7 @@ const useGanttChartCycleIssues = ( priority: filters?.priority ? filters?.priority.join(",") : undefined, labels: filters?.labels ? filters?.labels.join(",") : undefined, created_by: filters?.created_by ? filters?.created_by.join(",") : undefined, + start_date: filters?.start_date ? filters?.start_date.join(",") : undefined, target_date: filters?.target_date ? filters?.target_date.join(",") : undefined, start_target_date: true, // to fetch only issues with a start and target date }; diff --git a/apps/app/hooks/gantt-chart/issue-view.tsx b/apps/app/hooks/gantt-chart/issue-view.tsx index 7e595a358..ba9465114 100644 --- a/apps/app/hooks/gantt-chart/issue-view.tsx +++ b/apps/app/hooks/gantt-chart/issue-view.tsx @@ -19,6 +19,7 @@ const useGanttChartIssues = (workspaceSlug: string | undefined, projectId: strin priority: filters?.priority ? filters?.priority.join(",") : undefined, labels: filters?.labels ? filters?.labels.join(",") : undefined, created_by: filters?.created_by ? filters?.created_by.join(",") : undefined, + start_date: filters?.start_date ? filters?.start_date.join(",") : undefined, target_date: filters?.target_date ? filters?.target_date.join(",") : undefined, start_target_date: true, // to fetch only issues with a start and target date }; diff --git a/apps/app/hooks/gantt-chart/module-issues-view.tsx b/apps/app/hooks/gantt-chart/module-issues-view.tsx index 54dea3e2e..3d88cab4f 100644 --- a/apps/app/hooks/gantt-chart/module-issues-view.tsx +++ b/apps/app/hooks/gantt-chart/module-issues-view.tsx @@ -23,6 +23,7 @@ const useGanttChartModuleIssues = ( priority: filters?.priority ? filters?.priority.join(",") : undefined, labels: filters?.labels ? filters?.labels.join(",") : undefined, created_by: filters?.created_by ? filters?.created_by.join(",") : undefined, + start_date: filters?.start_date ? filters?.start_date.join(",") : undefined, target_date: filters?.target_date ? filters?.target_date.join(",") : undefined, start_target_date: true, // to fetch only issues with a start and target date }; diff --git a/apps/app/hooks/use-calendar-issues-view.tsx b/apps/app/hooks/use-calendar-issues-view.tsx index d8daae922..d69864c2d 100644 --- a/apps/app/hooks/use-calendar-issues-view.tsx +++ b/apps/app/hooks/use-calendar-issues-view.tsx @@ -42,6 +42,7 @@ const useCalendarIssuesView = () => { type: filters?.type ? filters?.type : undefined, labels: filters?.labels ? filters?.labels.join(",") : undefined, created_by: filters?.created_by ? filters?.created_by.join(",") : undefined, + start_date: filters?.start_date ? filters?.start_date.join(",") : undefined, target_date: calendarDateRange, }; diff --git a/apps/app/hooks/use-spreadsheet-issues-view.tsx b/apps/app/hooks/use-spreadsheet-issues-view.tsx index 797ddf7d6..4471b4352 100644 --- a/apps/app/hooks/use-spreadsheet-issues-view.tsx +++ b/apps/app/hooks/use-spreadsheet-issues-view.tsx @@ -43,6 +43,8 @@ const useSpreadsheetIssuesView = () => { type: filters?.type ? filters?.type : undefined, labels: filters?.labels ? filters?.labels.join(",") : undefined, created_by: filters?.created_by ? filters?.created_by.join(",") : undefined, + start_date: filters?.start_date ? filters?.start_date.join(",") : undefined, + target_date: filters?.target_date ? filters?.target_date.join(",") : undefined, sub_issue: "false", }; From d8bbdc14ac0c6996541e92fbcbe3d4ebba7e2496 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 29 Aug 2023 20:14:13 +0530 Subject: [PATCH 13/27] feat: access selector for comment (#2012) * dev: access specifier for comment * chore: change access order --- .../components/issues/comment/add-comment.tsx | 112 ++++++++++++------ apps/app/components/issues/main-content.tsx | 9 +- apps/app/services/issues.service.ts | 14 +-- apps/app/types/issues.d.ts | 1 + apps/app/types/projects.d.ts | 2 +- 5 files changed, 93 insertions(+), 45 deletions(-) diff --git a/apps/app/components/issues/comment/add-comment.tsx b/apps/app/components/issues/comment/add-comment.tsx index 4d64a90ba..33d7f2289 100644 --- a/apps/app/components/issues/comment/add-comment.tsx +++ b/apps/app/components/issues/comment/add-comment.tsx @@ -3,38 +3,55 @@ import { useRouter } from "next/router"; // react-hook-form import { useForm, Controller } from "react-hook-form"; // components -import { SecondaryButton } from "components/ui"; import { TipTapEditor } from "components/tiptap"; +// ui +import { Icon, SecondaryButton, Tooltip } from "components/ui"; // types import type { IIssueComment } from "types"; const defaultValues: Partial<IIssueComment> = { - comment_json: "", + access: "INTERNAL", comment_html: "", }; type Props = { disabled?: boolean; onSubmit: (data: IIssueComment) => Promise<void>; + showAccessSpecifier?: boolean; }; -export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => { - const { - control, - formState: { isSubmitting }, - handleSubmit, - reset, - setValue, - watch, - } = useForm<IIssueComment>({ defaultValues }); +const commentAccess = [ + { + icon: "lock", + key: "INTERNAL", + label: "Private", + }, + { + icon: "public", + key: "EXTERNAL", + label: "Public", + }, +]; +export const AddComment: React.FC<Props> = ({ + disabled = false, + onSubmit, + showAccessSpecifier = false, +}) => { const editorRef = React.useRef<any>(null); const router = useRouter(); const { workspaceSlug } = router.query; + const { + control, + formState: { isSubmitting }, + handleSubmit, + reset, + } = useForm<IIssueComment>({ defaultValues }); + const handleAddComment = async (formData: IIssueComment) => { - if (!formData.comment_html || !formData.comment_json || isSubmitting) return; + if (!formData.comment_html || isSubmitting) return; await onSubmit(formData).then(() => { reset(defaultValues); @@ -45,30 +62,55 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => { return ( <div> <form onSubmit={handleSubmit(handleAddComment)}> - <div className="issue-comments-section"> - <Controller - name="comment_html" - control={control} - render={({ field: { value, onChange } }) => ( - <TipTapEditor - workspaceSlug={workspaceSlug as string} - ref={editorRef} - value={ - !value || - value === "" || - (typeof value === "object" && Object.keys(value).length === 0) - ? watch("comment_html") - : value - } - customClassName="p-3 min-h-[50px] shadow-sm" - debouncedUpdatesEnabled={false} - onChange={(comment_json: Object, comment_html: string) => { - onChange(comment_html); - setValue("comment_json", comment_json); - }} - /> + <div> + <div className="relative"> + {showAccessSpecifier && ( + <div className="absolute bottom-2 left-3 z-[1]"> + <Controller + control={control} + name="access" + render={({ field: { onChange, value } }) => ( + <div className="flex border border-custom-border-300 divide-x divide-custom-border-300 rounded overflow-hidden"> + {commentAccess.map((access) => ( + <Tooltip key={access.key} tooltipContent={access.label}> + <button + type="button" + onClick={() => onChange(access.key)} + className={`grid place-items-center p-1 hover:bg-custom-background-80 ${ + value === access.key ? "bg-custom-background-80" : "" + }`} + > + <Icon + iconName={access.icon} + className={`w-4 h-4 -mt-1 ${ + value === access.key + ? "!text-custom-text-100" + : "!text-custom-text-400" + }`} + /> + </button> + </Tooltip> + ))} + </div> + )} + /> + </div> )} - /> + <Controller + name="comment_html" + control={control} + render={({ field: { value, onChange } }) => ( + <TipTapEditor + workspaceSlug={workspaceSlug as string} + ref={editorRef} + value={!value || value === "" ? "<p></p>" : value} + customClassName="p-3 min-h-[100px] shadow-sm" + debouncedUpdatesEnabled={false} + onChange={(comment_json: Object, comment_html: string) => onChange(comment_html)} + /> + )} + /> + </div> <SecondaryButton type="submit" disabled={isSubmitting || disabled} className="mt-2"> {isSubmitting ? "Adding..." : "Comment"} diff --git a/apps/app/components/issues/main-content.tsx b/apps/app/components/issues/main-content.tsx index ed559beb7..bab384523 100644 --- a/apps/app/components/issues/main-content.tsx +++ b/apps/app/components/issues/main-content.tsx @@ -8,6 +8,7 @@ import issuesService from "services/issues.service"; // hooks import useUserAuth from "hooks/use-user-auth"; import useToast from "hooks/use-toast"; +import useProjectDetails from "hooks/use-project-details"; // contexts import { useProjectMyMembership } from "contexts/project-member.context"; // components @@ -49,6 +50,8 @@ export const IssueMainContent: React.FC<Props> = ({ const { user } = useUserAuth(); const { memberRole } = useProjectMyMembership(); + const { projectDetails } = useProjectDetails(); + const { data: siblingIssues } = useSWR( workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null, workspaceSlug && projectId && issueDetails?.parent @@ -220,7 +223,11 @@ export const IssueMainContent: React.FC<Props> = ({ handleCommentUpdate={handleCommentUpdate} handleCommentDelete={handleCommentDelete} /> - <AddComment onSubmit={handleAddComment} disabled={uneditable} /> + <AddComment + onSubmit={handleAddComment} + disabled={uneditable} + showAccessSpecifier={projectDetails && projectDetails.is_deployed} + /> </div> </> ); diff --git a/apps/app/services/issues.service.ts b/apps/app/services/issues.service.ts index 53c6c1a2d..b8875e6c5 100644 --- a/apps/app/services/issues.service.ts +++ b/apps/app/services/issues.service.ts @@ -182,7 +182,7 @@ class ProjectIssuesServices extends APIService { workspaceSlug: string, projectId: string, issueId: string, - data: any, + data: Partial<IIssueComment>, user: ICurrentUserResponse | undefined ): Promise<any> { return this.post( @@ -468,20 +468,18 @@ class ProjectIssuesServices extends APIService { metadata: any; title: string; url: string; - }, - + } ): Promise<any> { return this.patch( `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-links/${linkId}/`, data ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); } - async deleteIssueLink( workspaceSlug: string, projectId: string, diff --git a/apps/app/types/issues.d.ts b/apps/app/types/issues.d.ts index 98d72041a..93e1598f3 100644 --- a/apps/app/types/issues.d.ts +++ b/apps/app/types/issues.d.ts @@ -198,6 +198,7 @@ export interface IIssueActivity { } export interface IIssueComment extends IIssueActivity { + access: "EXTERNAL" | "INTERNAL"; comment_html: string; comment_json: any; comment_stripped: string; diff --git a/apps/app/types/projects.d.ts b/apps/app/types/projects.d.ts index a3d8b997a..78ef4d953 100644 --- a/apps/app/types/projects.d.ts +++ b/apps/app/types/projects.d.ts @@ -39,6 +39,7 @@ export interface IProject { } | null; id: string; identifier: string; + is_deployed: boolean; is_favorite: boolean; is_member: boolean; member_role: 5 | 10 | 15 | 20 | null; @@ -57,7 +58,6 @@ export interface IProject { updated_by: string; workspace: IWorkspace | string; workspace_detail: IWorkspaceLite; - is_deployed: boolean; } export interface IProjectLite { From 168e79d6df2c896ef29a98c2361cd4dc691d64c8 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 29 Aug 2023 20:15:12 +0530 Subject: [PATCH 14/27] style: revamp of the issue details sidebar (#2014) --- .../issues/sidebar-select/assignee.tsx | 4 +- .../issues/sidebar-select/blocked.tsx | 12 +- .../issues/sidebar-select/blocker.tsx | 12 +- .../issues/sidebar-select/cycle.tsx | 107 +++++++-------- .../issues/sidebar-select/estimate.tsx | 4 +- .../issues/sidebar-select/module.tsx | 116 ++++++++-------- .../issues/sidebar-select/parent.tsx | 68 ++++------ .../issues/sidebar-select/priority.tsx | 2 +- .../issues/sidebar-select/state.tsx | 2 +- apps/app/components/issues/sidebar.tsx | 126 +++++++++++------- 10 files changed, 219 insertions(+), 234 deletions(-) diff --git a/apps/app/components/issues/sidebar-select/assignee.tsx b/apps/app/components/issues/sidebar-select/assignee.tsx index ad7db744f..61ece6f78 100644 --- a/apps/app/components/issues/sidebar-select/assignee.tsx +++ b/apps/app/components/issues/sidebar-select/assignee.tsx @@ -48,10 +48,10 @@ export const SidebarAssigneeSelect: React.FC<Props> = ({ value, onChange, disabl {value && value.length > 0 && Array.isArray(value) ? ( <div className="-my-0.5 flex items-center gap-2"> <AssigneesList userIds={value} length={3} showLength={false} /> - <span className="text-custom-text-100 text-sm">{value.length} Assignees</span> + <span className="text-custom-text-100 text-xs">{value.length} Assignees</span> </div> ) : ( - <button type="button" className="bg-custom-background-80 px-2.5 py-0.5 text-sm rounded"> + <button type="button" className="bg-custom-background-80 px-2.5 py-0.5 text-xs rounded"> No assignees </button> )} diff --git a/apps/app/components/issues/sidebar-select/blocked.tsx b/apps/app/components/issues/sidebar-select/blocked.tsx index 76373700c..fbe58b4b2 100644 --- a/apps/app/components/issues/sidebar-select/blocked.tsx +++ b/apps/app/components/issues/sidebar-select/blocked.tsx @@ -18,7 +18,6 @@ type Props = { issueId?: string; submitChanges: (formData: Partial<IIssue>) => void; watch: UseFormWatch<IIssue>; - userAuth: UserAuth; disabled?: boolean; }; @@ -26,7 +25,6 @@ export const SidebarBlockedSelect: React.FC<Props> = ({ issueId, submitChanges, watch, - userAuth, disabled = false, }) => { const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); @@ -73,8 +71,6 @@ export const SidebarBlockedSelect: React.FC<Props> = ({ handleClose(); }; - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled; - return ( <> <ExistingIssuesListModal @@ -128,11 +124,11 @@ export const SidebarBlockedSelect: React.FC<Props> = ({ </div> <button type="button" - className={`flex w-full text-custom-text-200 ${ - isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" - } items-center justify-between gap-1 rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm duration-300 focus:outline-none`} + className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 ${ + disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" + }`} onClick={() => setIsBlockedModalOpen(true)} - disabled={isNotAllowed} + disabled={disabled} > Select issues </button> diff --git a/apps/app/components/issues/sidebar-select/blocker.tsx b/apps/app/components/issues/sidebar-select/blocker.tsx index c25adc49e..49a6bc505 100644 --- a/apps/app/components/issues/sidebar-select/blocker.tsx +++ b/apps/app/components/issues/sidebar-select/blocker.tsx @@ -18,7 +18,6 @@ type Props = { issueId?: string; submitChanges: (formData: Partial<IIssue>) => void; watch: UseFormWatch<IIssue>; - userAuth: UserAuth; disabled?: boolean; }; @@ -26,7 +25,6 @@ export const SidebarBlockerSelect: React.FC<Props> = ({ issueId, submitChanges, watch, - userAuth, disabled = false, }) => { const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false); @@ -73,8 +71,6 @@ export const SidebarBlockerSelect: React.FC<Props> = ({ handleClose(); }; - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled; - return ( <> <ExistingIssuesListModal @@ -130,11 +126,11 @@ export const SidebarBlockerSelect: React.FC<Props> = ({ </div> <button type="button" - className={`flex w-full text-custom-text-200 ${ - isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" - } items-center justify-between gap-1 rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm duration-300 focus:outline-none`} + className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 ${ + disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" + }`} onClick={() => setIsBlockerModalOpen(true)} - disabled={isNotAllowed} + disabled={disabled} > Select issues </button> diff --git a/apps/app/components/issues/sidebar-select/cycle.tsx b/apps/app/components/issues/sidebar-select/cycle.tsx index 1eacba245..3451be2c3 100644 --- a/apps/app/components/issues/sidebar-select/cycle.tsx +++ b/apps/app/components/issues/sidebar-select/cycle.tsx @@ -11,24 +11,20 @@ import cyclesService from "services/cycles.service"; import { Spinner, CustomSelect, Tooltip } from "components/ui"; // helper import { truncateText } from "helpers/string.helper"; -// icons -import { ContrastIcon } from "components/icons"; // types -import { ICycle, IIssue, UserAuth } from "types"; +import { ICycle, IIssue } from "types"; // fetch-keys import { CYCLE_ISSUES, INCOMPLETE_CYCLES_LIST, ISSUE_DETAILS } from "constants/fetch-keys"; type Props = { issueDetail: IIssue | undefined; handleCycleChange: (cycle: ICycle) => void; - userAuth: UserAuth; disabled?: boolean; }; export const SidebarCycleSelect: React.FC<Props> = ({ issueDetail, handleCycleChange, - userAuth, disabled = false, }) => { const router = useRouter(); @@ -63,59 +59,56 @@ export const SidebarCycleSelect: React.FC<Props> = ({ const issueCycle = issueDetail?.issue_cycle; - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled; - return ( - <div className="flex flex-wrap items-center py-2"> - <div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2"> - <ContrastIcon className="h-4 w-4 flex-shrink-0" /> - <p>Cycle</p> - </div> - <div className="space-y-1 sm:basis-1/2"> - <CustomSelect - label={ - <Tooltip - position="left" - tooltipContent={`${issueCycle ? issueCycle.cycle_detail.name : "No cycle"}`} - > - <span className="w-full max-w-[125px] truncate text-left sm:block"> - <span className={`${issueCycle ? "text-custom-text-100" : "text-custom-text-200"}`}> - {issueCycle ? truncateText(issueCycle.cycle_detail.name, 15) : "No cycle"} - </span> - </span> - </Tooltip> - } - value={issueCycle ? issueCycle.cycle_detail.id : null} - onChange={(value: any) => { - !value - ? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "") - : handleCycleChange(incompleteCycles?.find((c) => c.id === value) as ICycle); - }} - width="w-full" - position="right" - maxHeight="rg" - disabled={isNotAllowed} + <CustomSelect + customButton={ + <Tooltip + position="left" + tooltipContent={`${issueCycle ? issueCycle.cycle_detail.name : "No cycle"}`} > - {incompleteCycles ? ( - incompleteCycles.length > 0 ? ( - <> - {incompleteCycles.map((option) => ( - <CustomSelect.Option key={option.id} value={option.id}> - <Tooltip position="left-bottom" tooltipContent={option.name}> - <span className="w-full truncate">{truncateText(option.name, 25)}</span> - </Tooltip> - </CustomSelect.Option> - ))} - <CustomSelect.Option value={null}>None</CustomSelect.Option> - </> - ) : ( - <div className="text-center">No cycles found</div> - ) - ) : ( - <Spinner /> - )} - </CustomSelect> - </div> - </div> + <button + type="button" + className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 w-full flex ${ + disabled ? "cursor-not-allowed" : "" + }`} + > + <span + className={`truncate ${issueCycle ? "text-custom-text-100" : "text-custom-text-200"}`} + > + {issueCycle ? issueCycle.cycle_detail.name : "No cycle"} + </span> + </button> + </Tooltip> + } + value={issueCycle ? issueCycle.cycle_detail.id : null} + onChange={(value: any) => { + !value + ? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "") + : handleCycleChange(incompleteCycles?.find((c) => c.id === value) as ICycle); + }} + width="w-full" + position="right" + maxHeight="rg" + disabled={disabled} + > + {incompleteCycles ? ( + incompleteCycles.length > 0 ? ( + <> + {incompleteCycles.map((option) => ( + <CustomSelect.Option key={option.id} value={option.id}> + <Tooltip position="left-bottom" tooltipContent={option.name}> + <span className="w-full truncate">{truncateText(option.name, 25)}</span> + </Tooltip> + </CustomSelect.Option> + ))} + <CustomSelect.Option value={null}>None</CustomSelect.Option> + </> + ) : ( + <div className="text-center">No cycles found</div> + ) + ) : ( + <Spinner /> + )} + </CustomSelect> ); }; diff --git a/apps/app/components/issues/sidebar-select/estimate.tsx b/apps/app/components/issues/sidebar-select/estimate.tsx index 063694141..7ebdfe2b9 100644 --- a/apps/app/components/issues/sidebar-select/estimate.tsx +++ b/apps/app/components/issues/sidebar-select/estimate.tsx @@ -14,9 +14,7 @@ type Props = { }; export const SidebarEstimateSelect: React.FC<Props> = ({ value, onChange, disabled = false }) => { - const { isEstimateActive, estimatePoints } = useEstimateOption(); - - if (!isEstimateActive) return null; + const { estimatePoints } = useEstimateOption(); return ( <CustomSelect diff --git a/apps/app/components/issues/sidebar-select/module.tsx b/apps/app/components/issues/sidebar-select/module.tsx index a8770e03d..a183fe951 100644 --- a/apps/app/components/issues/sidebar-select/module.tsx +++ b/apps/app/components/issues/sidebar-select/module.tsx @@ -10,24 +10,20 @@ import modulesService from "services/modules.service"; import { Spinner, CustomSelect, Tooltip } from "components/ui"; // helper import { truncateText } from "helpers/string.helper"; -// icons -import { RectangleGroupIcon } from "@heroicons/react/24/outline"; // types -import { IIssue, IModule, UserAuth } from "types"; +import { IIssue, IModule } from "types"; // fetch-keys import { ISSUE_DETAILS, MODULE_ISSUES, MODULE_LIST } from "constants/fetch-keys"; type Props = { issueDetail: IIssue | undefined; handleModuleChange: (module: IModule) => void; - userAuth: UserAuth; disabled?: boolean; }; export const SidebarModuleSelect: React.FC<Props> = ({ issueDetail, handleModuleChange, - userAuth, disabled = false, }) => { const router = useRouter(); @@ -57,66 +53,60 @@ export const SidebarModuleSelect: React.FC<Props> = ({ const issueModule = issueDetail?.issue_module; - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled; - return ( - <div className="flex flex-wrap items-center py-2"> - <div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2"> - <RectangleGroupIcon className="h-4 w-4 flex-shrink-0" /> - <p>Module</p> - </div> - <div className="space-y-1 sm:basis-1/2"> - <CustomSelect - label={ - <Tooltip - position="left" - tooltipContent={`${ - modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module" + <CustomSelect + customButton={ + <Tooltip + position="left" + tooltipContent={`${ + modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module" + }`} + > + <button + type="button" + className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 w-full flex ${ + disabled ? "cursor-not-allowed" : "" + }`} + > + <span + className={`truncate ${ + issueModule ? "text-custom-text-100" : "text-custom-text-200" }`} > - <span className="w-full max-w-[125px] truncate text-left sm:block"> - <span - className={`${issueModule ? "text-custom-text-100" : "text-custom-text-200"}`} - > - {truncateText( - `${modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module"}`, - 15 - )} - </span> - </span> - </Tooltip> - } - value={issueModule ? issueModule.module_detail?.id : null} - onChange={(value: any) => { - !value - ? removeIssueFromModule(issueModule?.id ?? "", issueModule?.module ?? "") - : handleModuleChange(modules?.find((m) => m.id === value) as IModule); - }} - width="w-full" - position="right" - maxHeight="rg" - disabled={isNotAllowed} - > - {modules ? ( - modules.length > 0 ? ( - <> - {modules.map((option) => ( - <CustomSelect.Option key={option.id} value={option.id}> - <Tooltip position="left-bottom" tooltipContent={option.name}> - <span className="w-full truncate">{truncateText(option.name, 25)}</span> - </Tooltip> - </CustomSelect.Option> - ))} - <CustomSelect.Option value={null}>None</CustomSelect.Option> - </> - ) : ( - <div className="text-center">No modules found</div> - ) - ) : ( - <Spinner /> - )} - </CustomSelect> - </div> - </div> + {modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module"} + </span> + </button> + </Tooltip> + } + value={issueModule ? issueModule.module_detail?.id : null} + onChange={(value: any) => { + !value + ? removeIssueFromModule(issueModule?.id ?? "", issueModule?.module ?? "") + : handleModuleChange(modules?.find((m) => m.id === value) as IModule); + }} + width="w-full" + position="right" + maxHeight="rg" + disabled={disabled} + > + {modules ? ( + modules.length > 0 ? ( + <> + {modules.map((option) => ( + <CustomSelect.Option key={option.id} value={option.id}> + <Tooltip position="left-bottom" tooltipContent={option.name}> + <span className="w-full truncate">{truncateText(option.name, 25)}</span> + </Tooltip> + </CustomSelect.Option> + ))} + <CustomSelect.Option value={null}>None</CustomSelect.Option> + </> + ) : ( + <div className="text-center">No modules found</div> + ) + ) : ( + <Spinner /> + )} + </CustomSelect> ); }; diff --git a/apps/app/components/issues/sidebar-select/parent.tsx b/apps/app/components/issues/sidebar-select/parent.tsx index 1e780dd57..dd5d4f55b 100644 --- a/apps/app/components/issues/sidebar-select/parent.tsx +++ b/apps/app/components/issues/sidebar-select/parent.tsx @@ -2,8 +2,6 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; -// icons -import { UserIcon } from "@heroicons/react/24/outline"; // components import { ParentIssuesListModal } from "components/issues"; // types @@ -12,14 +10,12 @@ import { IIssue, ISearchIssueResponse, UserAuth } from "types"; type Props = { onChange: (value: string) => void; issueDetails: IIssue | undefined; - userAuth: UserAuth; disabled?: boolean; }; export const SidebarParentSelect: React.FC<Props> = ({ onChange, issueDetails, - userAuth, disabled = false, }) => { const [isParentModalOpen, setIsParentModalOpen] = useState(false); @@ -28,42 +24,34 @@ export const SidebarParentSelect: React.FC<Props> = ({ const router = useRouter(); const { projectId, issueId } = router.query; - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled; - return ( - <div className="flex flex-wrap items-center py-2"> - <div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2"> - <UserIcon className="h-4 w-4 flex-shrink-0" /> - <p>Parent</p> - </div> - <div className="sm:basis-1/2"> - <ParentIssuesListModal - isOpen={isParentModalOpen} - handleClose={() => setIsParentModalOpen(false)} - onChange={(issue) => { - onChange(issue.id); - setSelectedParentIssue(issue); - }} - issueId={issueId as string} - projectId={projectId as string} - /> - <button - type="button" - className={`flex w-full ${ - isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" - } items-center justify-between gap-1 rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm duration-300 focus:outline-none`} - onClick={() => setIsParentModalOpen(true)} - disabled={isNotAllowed} - > - {selectedParentIssue && issueDetails?.parent ? ( - `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}` - ) : !selectedParentIssue && issueDetails?.parent ? ( - `${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}` - ) : ( - <span className="text-custom-text-200">Select issue</span> - )} - </button> - </div> - </div> + <> + <ParentIssuesListModal + isOpen={isParentModalOpen} + handleClose={() => setIsParentModalOpen(false)} + onChange={(issue) => { + onChange(issue.id); + setSelectedParentIssue(issue); + }} + issueId={issueId as string} + projectId={projectId as string} + /> + <button + type="button" + className={`bg-custom-background-80 text-xs rounded px-2.5 py-0.5 ${ + disabled ? "cursor-not-allowed" : "cursor-pointer " + }`} + onClick={() => setIsParentModalOpen(true)} + disabled={disabled} + > + {selectedParentIssue && issueDetails?.parent ? ( + `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}` + ) : !selectedParentIssue && issueDetails?.parent ? ( + `${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}` + ) : ( + <span className="text-custom-text-200">Select issue</span> + )} + </button> + </> ); }; diff --git a/apps/app/components/issues/sidebar-select/priority.tsx b/apps/app/components/issues/sidebar-select/priority.tsx index dfaa712e2..fd1c77f28 100644 --- a/apps/app/components/issues/sidebar-select/priority.tsx +++ b/apps/app/components/issues/sidebar-select/priority.tsx @@ -18,7 +18,7 @@ export const SidebarPrioritySelect: React.FC<Props> = ({ value, onChange, disabl customButton={ <button type="button" - className={`flex items-center gap-1.5 text-left text-sm capitalize rounded px-2.5 py-0.5 ${ + className={`flex items-center gap-1.5 text-left text-xs capitalize rounded px-2.5 py-0.5 ${ value === "urgent" ? "border-red-500/20 bg-red-500/20 text-red-500" : value === "high" diff --git a/apps/app/components/issues/sidebar-select/state.tsx b/apps/app/components/issues/sidebar-select/state.tsx index a3874221f..5084c61bd 100644 --- a/apps/app/components/issues/sidebar-select/state.tsx +++ b/apps/app/components/issues/sidebar-select/state.tsx @@ -39,7 +39,7 @@ export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, disabled return ( <CustomSelect customButton={ - <button type="button" className="bg-custom-background-80 text-sm rounded px-2.5 py-0.5"> + <button type="button" className="bg-custom-background-80 text-xs rounded px-2.5 py-0.5"> {selectedState ? ( <div className="flex items-center gap-1.5 text-left text-custom-text-100"> {getStateGroupIcon( diff --git a/apps/app/components/issues/sidebar.tsx b/apps/app/components/issues/sidebar.tsx index 366e57ba7..4bdd30137 100644 --- a/apps/app/components/issues/sidebar.tsx +++ b/apps/app/components/issues/sidebar.tsx @@ -10,6 +10,7 @@ import { Controller, UseFormWatch } from "react-hook-form"; import useToast from "hooks/use-toast"; import useUserAuth from "hooks/use-user-auth"; import useUserIssueNotificationSubscription from "hooks/use-issue-notification-subscription"; +import useEstimateOption from "hooks/use-estimate-option"; // services import issuesService from "services/issues.service"; import modulesService from "services/modules.service"; @@ -42,6 +43,8 @@ import { ChartBarIcon, UserGroupIcon, PlayIcon, + UserIcon, + RectangleGroupIcon, } from "@heroicons/react/24/outline"; // helpers import { copyTextToClipboard } from "helpers/string.helper"; @@ -49,6 +52,7 @@ import { copyTextToClipboard } from "helpers/string.helper"; import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types"; // fetch-keys import { ISSUE_DETAILS } from "constants/fetch-keys"; +import { ContrastIcon } from "components/icons"; type Props = { control: any; @@ -93,6 +97,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({ const { user } = useUserAuth(); + const { isEstimateActive } = useEstimateOption(); + const { loading, handleSubscribe, handleUnsubscribe, subscribed } = useUserIssueNotificationSubscription(workspaceSlug, projectId, issueId); @@ -403,22 +409,51 @@ export const IssueDetailsSidebar: React.FC<Props> = ({ </div> </div> )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && ( + {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && + isEstimateActive && ( + <div className="flex flex-wrap items-center py-2"> + <div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2"> + <PlayIcon className="h-4 w-4 flex-shrink-0 -rotate-90" /> + <p>Estimate</p> + </div> + <div className="sm:basis-1/2"> + <Controller + control={control} + name="estimate_point" + render={({ field: { value } }) => ( + <SidebarEstimateSelect + value={value} + onChange={(val: number | null) => + submitChanges({ estimate_point: val }) + } + disabled={memberRole.isGuest || memberRole.isViewer || uneditable} + /> + )} + /> + </div> + </div> + )} + </div> + )} + {showSecondSection && ( + <div className="py-1"> + {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( <div className="flex flex-wrap items-center py-2"> <div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2"> - <PlayIcon className="h-4 w-4 flex-shrink-0 -rotate-90" /> - <p>Estimate</p> + <UserIcon className="h-4 w-4 flex-shrink-0" /> + <p>Parent</p> </div> <div className="sm:basis-1/2"> <Controller control={control} - name="estimate_point" - render={({ field: { value } }) => ( - <SidebarEstimateSelect - value={value} - onChange={(val: number | null) => - submitChanges({ estimate_point: val }) - } + name="parent" + render={({ field: { onChange } }) => ( + <SidebarParentSelect + onChange={(val: string) => { + submitChanges({ parent: val }); + onChange(val); + }} + issueDetails={issueDetail} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} /> )} @@ -426,34 +461,12 @@ export const IssueDetailsSidebar: React.FC<Props> = ({ </div> </div> )} - </div> - )} - {showSecondSection && ( - <div className="py-1"> - {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( - <Controller - control={control} - name="parent" - render={({ field: { onChange } }) => ( - <SidebarParentSelect - onChange={(val: string) => { - submitChanges({ parent: val }); - onChange(val); - }} - issueDetails={issueDetail} - userAuth={memberRole} - disabled={uneditable} - /> - )} - /> - )} {(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && ( <SidebarBlockerSelect issueId={issueId as string} submitChanges={submitChanges} watch={watchIssue} - userAuth={memberRole} - disabled={uneditable} + disabled={memberRole.isGuest || memberRole.isViewer || uneditable} /> )} {(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && ( @@ -461,8 +474,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({ issueId={issueId as string} submitChanges={submitChanges} watch={watchIssue} - userAuth={memberRole} - disabled={uneditable} + disabled={memberRole.isGuest || memberRole.isViewer || uneditable} /> )} {(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && ( @@ -484,8 +496,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({ start_date: val, }) } - className="bg-custom-background-100" - wrapperClassName="w-full" + className="bg-custom-background-80 border-none" maxDate={maxDate ?? undefined} disabled={isNotAllowed || uneditable} /> @@ -513,8 +524,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({ target_date: val, }) } - className="bg-custom-background-100" - wrapperClassName="w-full" + className="bg-custom-background-80 border-none" minDate={minDate ?? undefined} disabled={isNotAllowed || uneditable} /> @@ -528,20 +538,34 @@ export const IssueDetailsSidebar: React.FC<Props> = ({ {showThirdSection && ( <div className="py-1"> {(fieldsToShow.includes("all") || fieldsToShow.includes("cycle")) && ( - <SidebarCycleSelect - issueDetail={issueDetail} - handleCycleChange={handleCycleChange} - userAuth={memberRole} - disabled={uneditable} - /> + <div className="flex flex-wrap items-center py-2"> + <div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2"> + <ContrastIcon className="h-4 w-4 flex-shrink-0" /> + <p>Cycle</p> + </div> + <div className="space-y-1 sm:w-1/2"> + <SidebarCycleSelect + issueDetail={issueDetail} + handleCycleChange={handleCycleChange} + disabled={memberRole.isGuest || memberRole.isViewer || uneditable} + /> + </div> + </div> )} {(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && ( - <SidebarModuleSelect - issueDetail={issueDetail} - handleModuleChange={handleModuleChange} - userAuth={memberRole} - disabled={uneditable} - /> + <div className="flex flex-wrap items-center py-2"> + <div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2"> + <RectangleGroupIcon className="h-4 w-4 flex-shrink-0" /> + <p>Module</p> + </div> + <div className="space-y-1 sm:w-1/2"> + <SidebarModuleSelect + issueDetail={issueDetail} + handleModuleChange={handleModuleChange} + disabled={memberRole.isGuest || memberRole.isViewer || uneditable} + /> + </div> + </div> )} </div> )} From 2e5ade05fe4e6439cedf010062fb58ed227ca84f Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 30 Aug 2023 11:43:47 +0530 Subject: [PATCH 15/27] chore: update module status icons and colors (#2011) * chore: update module status icons and colors * refactor: import statements * fix: add default alue to module status --- apps/app/components/icons/index.ts | 1 + apps/app/components/icons/module/backlog.tsx | 57 +++++++++++++++ .../app/components/icons/module/cancelled.tsx | 35 +++++++++ .../app/components/icons/module/completed.tsx | 28 ++++++++ .../components/icons/module/in-progress.tsx | 71 +++++++++++++++++++ apps/app/components/icons/module/index.ts | 7 ++ .../icons/module/module-status-icon.tsx | 37 ++++++++++ apps/app/components/icons/module/paused.tsx | 31 ++++++++ apps/app/components/icons/module/planned.tsx | 24 +++++++ .../components/modules/gantt-chart/blocks.tsx | 3 + apps/app/components/modules/select/status.tsx | 15 +--- apps/app/components/modules/sidebar.tsx | 2 +- apps/app/constants/module.ts | 21 ++++-- apps/app/types/modules.d.ts | 10 ++- 14 files changed, 321 insertions(+), 21 deletions(-) create mode 100644 apps/app/components/icons/module/backlog.tsx create mode 100644 apps/app/components/icons/module/cancelled.tsx create mode 100644 apps/app/components/icons/module/completed.tsx create mode 100644 apps/app/components/icons/module/in-progress.tsx create mode 100644 apps/app/components/icons/module/index.ts create mode 100644 apps/app/components/icons/module/module-status-icon.tsx create mode 100644 apps/app/components/icons/module/paused.tsx create mode 100644 apps/app/components/icons/module/planned.tsx diff --git a/apps/app/components/icons/index.ts b/apps/app/components/icons/index.ts index 183b20c97..d3b311e40 100644 --- a/apps/app/components/icons/index.ts +++ b/apps/app/components/icons/index.ts @@ -27,6 +27,7 @@ export * from "./started-state-icon"; export * from "./layer-diagonal-icon"; export * from "./lock-icon"; export * from "./menu-icon"; +export * from "./module"; export * from "./pencil-scribble-icon"; export * from "./plus-icon"; export * from "./person-running-icon"; diff --git a/apps/app/components/icons/module/backlog.tsx b/apps/app/components/icons/module/backlog.tsx new file mode 100644 index 000000000..5685c7498 --- /dev/null +++ b/apps/app/components/icons/module/backlog.tsx @@ -0,0 +1,57 @@ +import React from "react"; + +type Props = { + width?: string; + height?: string; + className?: string; + color?: string; +}; + +export const ModuleBacklogIcon: React.FC<Props> = ({ width = "20", height = "20", className }) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={width} + height={height} + className={className} + viewBox="0 0 247.63 247.6" + > + <g id="Layer_2" data-name="Layer 2"> + <g id="Layer_1-2" data-name="Layer 1"> + <path fill="#f6aa3e" d="M87.76,165.33a2.1,2.1,0,0,1-2.33,0Z" /> + <path fill="#f5a839" d="M94.08,165.33a1.67,1.67,0,0,1-2,0Z" /> + <path + fill="#a3a3a2" + d="M.29,115.46A130.18,130.18,0,0,1,2.05,101c.15-1,.53-1.37,1.62-1.15q7.78,1.64,15.6,3.12c1,.2,1.27.56,1.07,1.63a105.92,105.92,0,0,0-1.7,23.11,99.36,99.36,0,0,0,1.7,15.3c.2,1.05,0,1.44-1.06,1.64q-7.82,1.49-15.6,3.12c-1.22.25-1.5-.29-1.66-1.29C1.33,142,.64,137.63.34,133.16c0-.28-.05-.56-.34-.71v-.66c.36-.68.08-1.41.17-2.12A15,15,0,0,0,0,126.13v-4.66a17,17,0,0,0,.17-3.7A9.41,9.41,0,0,1,.29,115.46Z" + /> + <path + fill="#a3a3a2" + d="M132.14.29a130.64,130.64,0,0,1,14.48,1.76c1,.15,1.36.55,1.13,1.63q-1.62,7.79-3.11,15.6c-.2,1-.58,1.26-1.63,1.06a106.48,106.48,0,0,0-23-1.71,101.71,101.71,0,0,0-15.47,1.71c-1.08.21-1.42-.05-1.62-1.08-1-5.2-2-10.41-3.12-15.59-.23-1.1.17-1.47,1.15-1.62A137.72,137.72,0,0,1,115.46.28a5.78,5.78,0,0,1,1.66-.11h1.66A9,9,0,0,0,121.47,0h4.66a17,17,0,0,0,3.7.17A9.41,9.41,0,0,1,132.14.29Z" + /> + <path + fill="#a3a3a2" + d="M229,123.63a92.74,92.74,0,0,0-1.71-18.81c-.25-1.28.09-1.7,1.31-1.93q7.57-1.44,15.12-3c1.13-.24,1.66,0,1.88,1.22a133,133,0,0,1,2,26.28,141.92,141.92,0,0,1-2,19.29c-.19,1.08-.71,1.27-1.69,1.07q-7.71-1.58-15.45-3.07c-1.07-.21-1.36-.6-1.15-1.73A98.45,98.45,0,0,0,229,123.63Z" + /> + <path + fill="#a3a3a2" + d="M123.83,247.6a131.89,131.89,0,0,1-22.79-2c-1.14-.19-1.4-.68-1.18-1.76q1.61-7.71,3.09-15.45c.19-1,.45-1.34,1.6-1.12a105.9,105.9,0,0,0,23,1.72A101.84,101.84,0,0,0,143,227.26c1.05-.19,1.44,0,1.64,1.07q1.49,7.81,3.12,15.61c.22,1.08-.15,1.45-1.15,1.62A129.86,129.86,0,0,1,123.83,247.6Z" + /> + <path + fill="#a3a3a2" + d="M65.13,211.57c-.17.28-.37.62-.58.94-2.93,4.37-5.88,8.72-8.76,13.13-.6.91-1,1-1.92.4a126.44,126.44,0,0,1-32-32c-.8-1.15-.84-1.7.45-2.52,4.35-2.77,8.61-5.66,12.86-8.56.9-.62,1.33-.5,1.94.38a103.22,103.22,0,0,0,27.2,27.21C65.1,211.15,65.14,211.21,65.13,211.57Z" + /> + <path + fill="#a3a3a2" + d="M192.79,226.56c-.51-.06-.61-.4-.79-.67-3.05-4.55-6.08-9.12-9.17-13.65-.6-.89-.16-1.22.5-1.66a103.37,103.37,0,0,0,16.56-14.05,93.49,93.49,0,0,0,10.53-13c.68-1,1.15-1.13,2.18-.42,4.32,3,8.7,5.91,13.09,8.81.83.54,1,.94.38,1.8a125.4,125.4,0,0,1-32,32C193.65,226,193.18,226.31,192.79,226.56Z" + /> + <path + fill="#a3a3a2" + d="M36,65.13c-.28-.18-.62-.37-.94-.59-4.37-2.92-8.72-5.88-13.12-8.75-.95-.62-1-1-.36-1.91A127.22,127.22,0,0,1,53.69,21.72c1.07-.74,1.56-.65,2.27.46,2.79,4.32,5.67,8.6,8.57,12.85.63.91.68,1.38-.32,2.07A105,105,0,0,0,37,64.29C36.43,65.13,36.4,65.14,36,65.13Z" + /> + <path + fill="#a3a3a2" + d="M226.53,54.77c.07.52-.35.64-.66.85-4.56,3.05-9.12,6.08-13.65,9.15-.76.51-1.12.3-1.57-.38a97.64,97.64,0,0,0-11.79-14.34,97,97,0,0,0-15.37-12.87c-1.05-.7-1.09-1.18-.4-2.19,3-4.33,5.91-8.7,8.8-13.09.57-.86,1-.91,1.79-.32A126.12,126.12,0,0,1,225.92,53.8C226.14,54.11,226.33,54.45,226.53,54.77Z" + /> + </g> + </g> + </svg> +); diff --git a/apps/app/components/icons/module/cancelled.tsx b/apps/app/components/icons/module/cancelled.tsx new file mode 100644 index 000000000..9bfc02943 --- /dev/null +++ b/apps/app/components/icons/module/cancelled.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +type Props = { + width?: string; + height?: string; + className?: string; + color?: string; +}; + +export const ModuleCancelledIcon: React.FC<Props> = ({ + width = "20", + height = "20", + className, +}) => ( + <svg + width={width} + height={height} + className={className} + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <g clip-path="url(#clip0_4052_100277)"> + <path + d="M8 8.84L10.58 11.42C10.7 11.54 10.84 11.6 11 11.6C11.16 11.6 11.3 11.54 11.42 11.42C11.54 11.3 11.6 11.16 11.6 11C11.6 10.84 11.54 10.7 11.42 10.58L8.84 8L11.42 5.42C11.54 5.3 11.6 5.16 11.6 5C11.6 4.84 11.54 4.7 11.42 4.58C11.3 4.46 11.16 4.4 11 4.4C10.84 4.4 10.7 4.46 10.58 4.58L8 7.16L5.42 4.58C5.3 4.46 5.16 4.4 5 4.4C4.84 4.4 4.7 4.46 4.58 4.58C4.46 4.7 4.4 4.84 4.4 5C4.4 5.16 4.46 5.3 4.58 5.42L7.16 8L4.58 10.58C4.46 10.7 4.4 10.84 4.4 11C4.4 11.16 4.46 11.3 4.58 11.42C4.7 11.54 4.84 11.6 5 11.6C5.16 11.6 5.3 11.54 5.42 11.42L8 8.84ZM8 16C6.90667 16 5.87333 15.79 4.9 15.37C3.92667 14.95 3.07667 14.3767 2.35 13.65C1.62333 12.9233 1.05 12.0733 0.63 11.1C0.21 10.1267 0 9.09333 0 8C0 6.89333 0.21 5.85333 0.63 4.88C1.05 3.90667 1.62333 3.06 2.35 2.34C3.07667 1.62 3.92667 1.05 4.9 0.63C5.87333 0.21 6.90667 0 8 0C9.10667 0 10.1467 0.21 11.12 0.63C12.0933 1.05 12.94 1.62 13.66 2.34C14.38 3.06 14.95 3.90667 15.37 4.88C15.79 5.85333 16 6.89333 16 8C16 9.09333 15.79 10.1267 15.37 11.1C14.95 12.0733 14.38 12.9233 13.66 13.65C12.94 14.3767 12.0933 14.95 11.12 15.37C10.1467 15.79 9.10667 16 8 16ZM8 14.8C9.89333 14.8 11.5 14.1367 12.82 12.81C14.14 11.4833 14.8 9.88 14.8 8C14.8 6.10667 14.14 4.5 12.82 3.18C11.5 1.86 9.89333 1.2 8 1.2C6.12 1.2 4.51667 1.86 3.19 3.18C1.86333 4.5 1.2 6.10667 1.2 8C1.2 9.88 1.86333 11.4833 3.19 12.81C4.51667 14.1367 6.12 14.8 8 14.8Z" + fill="#ef4444" + /> + </g> + <defs> + <clipPath id="clip0_4052_100277"> + <rect width="16" height="16" fill="white" /> + </clipPath> + </defs> + </svg> +); diff --git a/apps/app/components/icons/module/completed.tsx b/apps/app/components/icons/module/completed.tsx new file mode 100644 index 000000000..4c50ed3ad --- /dev/null +++ b/apps/app/components/icons/module/completed.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +type Props = { + width?: string; + height?: string; + className?: string; + color?: string; +}; + +export const ModuleCompletedIcon: React.FC<Props> = ({ + width = "20", + height = "20", + className, +}) => ( + <svg + width={width} + height={height} + className={className} + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M6.80486 9.80731L4.84856 7.85103C4.73197 7.73443 4.58542 7.67478 4.4089 7.67208C4.23238 7.66937 4.08312 7.72902 3.96113 7.85103C3.83913 7.97302 3.77814 8.12093 3.77814 8.29474C3.77814 8.46855 3.83913 8.61645 3.96113 8.73844L6.27206 11.0494C6.42428 11.2016 6.60188 11.2777 6.80486 11.2777C7.00782 11.2777 7.18541 11.2016 7.33764 11.0494L12.0227 6.36435C12.1393 6.24776 12.1989 6.10121 12.2016 5.92469C12.2043 5.74817 12.1447 5.59891 12.0227 5.47692C11.9007 5.35493 11.7528 5.29393 11.579 5.29393C11.4051 5.29393 11.2572 5.35493 11.1353 5.47692L6.80486 9.80731ZM8.00141 16C6.89494 16 5.85491 15.79 4.88132 15.3701C3.90772 14.9502 3.06082 14.3803 2.34064 13.6604C1.62044 12.9405 1.05028 12.094 0.63017 11.1208C0.210057 10.1477 0 9.10788 0 8.00141C0 6.89494 0.209966 5.85491 0.629896 4.88132C1.04983 3.90772 1.61972 3.06082 2.33958 2.34064C3.05946 1.62044 3.90598 1.05028 4.87915 0.630171C5.8523 0.210058 6.89212 0 7.99859 0C9.10506 0 10.1451 0.209966 11.1187 0.629897C12.0923 1.04983 12.9392 1.61972 13.6594 2.33959C14.3796 3.05946 14.9497 3.90598 15.3698 4.87915C15.7899 5.8523 16 6.89212 16 7.99859C16 9.10506 15.79 10.1451 15.3701 11.1187C14.9502 12.0923 14.3803 12.9392 13.6604 13.6594C12.9405 14.3796 12.094 14.9497 11.1208 15.3698C10.1477 15.7899 9.10788 16 8.00141 16ZM8 14.7369C9.88071 14.7369 11.4737 14.0842 12.779 12.779C14.0842 11.4737 14.7369 9.88071 14.7369 8C14.7369 6.11929 14.0842 4.52631 12.779 3.22104C11.4737 1.91577 9.88071 1.26314 8 1.26314C6.11929 1.26314 4.52631 1.91577 3.22104 3.22104C1.91577 4.52631 1.26314 6.11929 1.26314 8C1.26314 9.88071 1.91577 11.4737 3.22104 12.779C4.52631 14.0842 6.11929 14.7369 8 14.7369Z" + fill="#16a34a" + /> + </svg> +); diff --git a/apps/app/components/icons/module/in-progress.tsx b/apps/app/components/icons/module/in-progress.tsx new file mode 100644 index 000000000..5892a94d6 --- /dev/null +++ b/apps/app/components/icons/module/in-progress.tsx @@ -0,0 +1,71 @@ +import React from "react"; + +type Props = { + width?: string; + height?: string; + className?: string; + color?: string; +}; + +export const ModuleInProgressIcon: React.FC<Props> = ({ + width = "20", + height = "20", + className, +}) => ( + <svg + width={width} + height={height} + className={className} + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 234.83 234.82" + > + <g id="Layer_2" data-name="Layer 2"> + <g id="Layer_1-2" data-name="Layer 1"> + <path fill="#f7b964" d="M0,111.14c.63.7.21,1.53.3,2.29-.07.26-.17.28-.3,0Z" /> + <path fill="#f6ab3e" d="M0,119.46a3.11,3.11,0,0,1,.3,2q-.19.33-.3,0Z" /> + <path + fill="#facf96" + d="M.27,123.16c0,.66.38,1.38-.27,2v-2C.13,122.89.22,122.91.27,123.16Z" + /> + <path fill="#f5a939" d="M0,113.47l.3,0a2.39,2.39,0,0,1-.3,1.71Z" /> + <path fill="#f8ba67" d="M.27,123.16a.63.63,0,0,1-.27,0v-1.66l.3,0Z" /> + <path + fill="#f39e1f" + d="M234.58,106.92a72,72,0,0,0-.65-8.42,117.08,117.08,0,0,0-13.46-38.74,118.87,118.87,0,0,0-31.73-36.49A115,115,0,0,0,151.17,4.14,83.24,83.24,0,0,0,134.28.58c-2.94-.24-5.89-.22-8.83-.58h-4a2.66,2.66,0,0,1-2,0h-4.32a3.45,3.45,0,0,1-2.33,0h-3.66c-.51.33-1.08.14-1.62.16A87.24,87.24,0,0,0,90,2.35,118.53,118.53,0,0,0,23.16,46,115.24,115.24,0,0,0,4.29,83,85.41,85.41,0,0,0,.6,100.15c-.26,3-.22,6-.6,9v2a6.63,6.63,0,0,1,.17,2.26c-.08.58.17,1.19-.17,1.74v4.32c.35.66.08,1.37.17,2.05v1.57c-.09.68.18,1.39-.17,2v.67c.3.39.14.85.16,1.28.2,3.18.22,6.38.66,9.53a101.21,101.21,0,0,0,4.27,17.76A118.17,118.17,0,0,0,99,234a100.25,100.25,0,0,0,11.37.65,167.86,167.86,0,0,0,23.84-.54,100.39,100.39,0,0,0,23.35-5.72,117.87,117.87,0,0,0,39.67-24.08,117.77,117.77,0,0,0,33.27-53.2,85.63,85.63,0,0,0,3.71-17.37A212.22,212.22,0,0,0,234.58,106.92ZM117.31,217a99.63,99.63,0,0,1-99.7-100.05c0-54.91,44.8-99.35,100.09-99.33,54.89,0,99.32,44.83,99.29,100.14C217,172.43,172.21,217,117.31,217Z" + /> + <path + fill="#f39e1f" + d="M117.33,44a84.49,84.49,0,0,1,12.9,1.15c1.09.19,1.37.56,1.15,1.6q-1.51,7.41-2.94,14.82c-.16.82-.45,1.11-1.33.95a53.31,53.31,0,0,0-19.67,0c-.77.14-1.11-.06-1.26-.83q-1.47-7.59-3-15.16c-.2-1,.21-1.19,1.08-1.35A80.7,80.7,0,0,1,117.33,44Z" + /> + <path + fill="#f39e1f" + d="M44,117.2a80.88,80.88,0,0,1,1.17-12.9c.18-1,.49-1.3,1.49-1.1q7.49,1.53,15,3c.89.18,1,.59.85,1.39a53.54,53.54,0,0,0,0,19.51c.15.83,0,1.2-.88,1.36-5,1-10,2-15,3-.85.17-1.25,0-1.43-1A82.68,82.68,0,0,1,44,117.2Z" + /> + <path + fill="#f39e1f" + d="M190.64,117.39a80.88,80.88,0,0,1-1.17,12.9c-.18,1-.46,1.32-1.48,1.11q-7.49-1.53-15-3c-.88-.17-1-.57-.86-1.38a53.54,53.54,0,0,0,0-19.51c-.18-1,.16-1.23,1-1.39q7.33-1.41,14.66-2.91c1-.21,1.46-.09,1.65,1.08A86.71,86.71,0,0,1,190.64,117.39Z" + /> + <path + fill="#f39e1f" + d="M117.28,190.64a83.24,83.24,0,0,1-12.9-1.15c-1.07-.19-1.38-.53-1.16-1.6q1.52-7.39,2.94-14.82c.16-.8.43-1.12,1.32-.95a53.31,53.31,0,0,0,19.67,0c.92-.17,1.14.2,1.29,1q1.44,7.42,2.95,14.82c.19.95,0,1.35-1,1.54A83,83,0,0,1,117.28,190.64Z" + /> + <path + fill="#f39e1f" + d="M70.7,86.15,70,85.74c-4.23-2.84-8.45-5.69-12.71-8.49-.76-.5-.93-.86-.36-1.67A75.59,75.59,0,0,1,75.41,57.11c.85-.6,1.29-.66,1.93.33,2.71,4.18,5.5,8.3,8.3,12.42.53.78.62,1.18-.28,1.81A54.6,54.6,0,0,0,71.68,85.32C71.07,86.18,71.05,86.17,70.7,86.15Z" + /> + <path + fill="#f39e1f" + d="M178,76.28c.05.58-.38.7-.69.9-4.27,2.87-8.55,5.72-12.82,8.6-.6.41-1,.47-1.44-.23a54.76,54.76,0,0,0-14-14c-.66-.46-.69-.8-.25-1.45q4.33-6.39,8.59-12.83c.47-.72.82-.84,1.56-.33A74.64,74.64,0,0,1,177.53,75.5C177.72,75.77,177.89,76.06,178,76.28Z" + /> + <path + fill="#f39e1f" + d="M70.68,148.46c.48-.11.59.27.77.52A55.65,55.65,0,0,0,85.59,163.1c.58.4.66.72.26,1.32q-4.38,6.47-8.69,13c-.41.63-.74.81-1.43.32a74.65,74.65,0,0,1-18.8-18.8c-.42-.61-.48-1,.23-1.46,4.34-2.87,8.65-5.78,13-8.67C70.32,148.67,70.52,148.56,70.68,148.46Z" + /> + <path + fill="#f39e1f" + d="M158.24,178.06c-.56-.08-.67-.5-.87-.8-2.84-4.23-5.66-8.47-8.52-12.68-.47-.69-.47-1,.29-1.56A54.46,54.46,0,0,0,163,149.11c.53-.77.9-.7,1.57-.24q6.33,4.29,12.7,8.49c.81.53.86.91.32,1.68a74.06,74.06,0,0,1-18.46,18.45C158.84,177.71,158.5,177.9,158.24,178.06Z" + /> + </g> + </g> + </svg> +); diff --git a/apps/app/components/icons/module/index.ts b/apps/app/components/icons/module/index.ts new file mode 100644 index 000000000..e82014b2f --- /dev/null +++ b/apps/app/components/icons/module/index.ts @@ -0,0 +1,7 @@ +export * from "./backlog"; +export * from "./cancelled"; +export * from "./completed"; +export * from "./in-progress"; +export * from "./module-status-icon"; +export * from "./paused"; +export * from "./planned"; diff --git a/apps/app/components/icons/module/module-status-icon.tsx b/apps/app/components/icons/module/module-status-icon.tsx new file mode 100644 index 000000000..e80497773 --- /dev/null +++ b/apps/app/components/icons/module/module-status-icon.tsx @@ -0,0 +1,37 @@ +// icons +import { + ModuleBacklogIcon, + ModuleCancelledIcon, + ModuleCompletedIcon, + ModuleInProgressIcon, + ModulePausedIcon, + ModulePlannedIcon, +} from "components/icons"; +// types +import { TModuleStatus } from "types"; + +type Props = { + status: TModuleStatus; + className?: string; + height?: string; + width?: string; +}; + +export const ModuleStatusIcon: React.FC<Props> = ({ + status, + className, + height = "12px", + width = "12px", +}) => { + if (status === "backlog") + return <ModuleBacklogIcon className={className} height={height} width={width} />; + else if (status === "cancelled") + return <ModuleCancelledIcon className={className} height={height} width={width} />; + else if (status === "completed") + return <ModuleCompletedIcon className={className} height={height} width={width} />; + else if (status === "in-progress") + return <ModuleInProgressIcon className={className} height={height} width={width} />; + else if (status === "paused") + return <ModulePausedIcon className={className} height={height} width={width} />; + else return <ModulePlannedIcon className={className} height={height} width={width} />; +}; diff --git a/apps/app/components/icons/module/paused.tsx b/apps/app/components/icons/module/paused.tsx new file mode 100644 index 000000000..56ebcfd98 --- /dev/null +++ b/apps/app/components/icons/module/paused.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +type Props = { + width?: string; + height?: string; + className?: string; + color?: string; +}; + +export const ModulePausedIcon: React.FC<Props> = ({ width = "20", height = "20", className }) => ( + <svg + width={width} + height={height} + className={className} + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <g clip-path="url(#clip0_4052_100275)"> + <path + d="M6.4435 10.34C6.6145 10.34 6.75667 10.2825 6.87 10.1675C6.98333 10.0525 7.04 9.91 7.04 9.74V6.24C7.04 6.07 6.98217 5.9275 6.8665 5.8125C6.75082 5.6975 6.60749 5.64 6.4365 5.64C6.2655 5.64 6.12333 5.6975 6.01 5.8125C5.89667 5.9275 5.84 6.07 5.84 6.24V9.74C5.84 9.91 5.89783 10.0525 6.0135 10.1675C6.12918 10.2825 6.27251 10.34 6.4435 10.34ZM9.5635 10.34C9.7345 10.34 9.87667 10.2825 9.99 10.1675C10.1033 10.0525 10.16 9.91 10.16 9.74V6.24C10.16 6.07 10.1022 5.9275 9.9865 5.8125C9.87082 5.6975 9.72749 5.64 9.5565 5.64C9.3855 5.64 9.24333 5.6975 9.13 5.8125C9.01667 5.9275 8.96 6.07 8.96 6.24V9.74C8.96 9.91 9.01783 10.0525 9.1335 10.1675C9.24918 10.2825 9.39251 10.34 9.5635 10.34ZM8 16C6.89333 16 5.85333 15.79 4.88 15.37C3.90667 14.95 3.06 14.38 2.34 13.66C1.62 12.94 1.05 12.0933 0.63 11.12C0.21 10.1467 0 9.10667 0 8C0 7.54667 0.0366667 7.09993 0.11 6.6598C0.183333 6.21965 0.293333 5.78639 0.44 5.36C0.493333 5.21333 0.593333 5.11667 0.74 5.07C0.886667 5.02333 1.02667 5.04199 1.16 5.12596C1.30285 5.20993 1.40523 5.33327 1.46714 5.49596C1.52905 5.65865 1.54 5.82 1.5 5.98C1.42 6.31333 1.35 6.64765 1.29 6.98294C1.23 7.31823 1.2 7.65725 1.2 8C1.2 9.89833 1.85875 11.5063 3.17624 12.8238C4.49375 14.1413 6.10167 14.8 8 14.8C9.89833 14.8 11.5063 14.1413 12.8238 12.8238C14.1413 11.5063 14.8 9.89833 14.8 8C14.8 6.10167 14.1413 4.49375 12.8238 3.17624C11.5063 1.85875 9.89833 1.2 8 1.2C7.63235 1.2 7.26852 1.22667 6.90852 1.28C6.54852 1.33333 6.19235 1.41333 5.84 1.52C5.68 1.57333 5.52 1.56667 5.36 1.5C5.2 1.43333 5.08667 1.32667 5.02 1.18C4.95333 1.03333 4.96 0.886667 5.04 0.74C5.12 0.593333 5.23333 0.493333 5.38 0.44C5.79333 0.306667 6.21333 0.2 6.64 0.12C7.06667 0.04 7.49333 0 7.92 0C9.02667 0 10.07 0.21 11.05 0.63C12.03 1.05 12.8863 1.62 13.6189 2.34C14.3516 3.06 14.9316 3.90667 15.3589 4.88C15.7863 5.85333 16 6.89333 16 8C16 9.10667 15.79 10.1467 15.37 11.12C14.95 12.0933 14.38 12.94 13.66 13.66C12.94 14.38 12.0933 14.95 11.12 15.37C10.1467 15.79 9.10667 16 8 16ZM2.65764 3.62C2.37921 3.62 2.14333 3.52255 1.95 3.32764C1.75667 3.13275 1.66 2.89608 1.66 2.61764C1.66 2.33921 1.75745 2.10333 1.95236 1.91C2.14725 1.71667 2.38392 1.62 2.66236 1.62C2.94079 1.62 3.17667 1.71745 3.37 1.91236C3.56333 2.10725 3.66 2.34392 3.66 2.62236C3.66 2.90079 3.56255 3.13667 3.36764 3.33C3.17275 3.52333 2.93608 3.62 2.65764 3.62Z" + fill="#525252" + /> + </g> + <defs> + <clipPath id="clip0_4052_100275"> + <rect width="16" height="16" fill="white" /> + </clipPath> + </defs> + </svg> +); diff --git a/apps/app/components/icons/module/planned.tsx b/apps/app/components/icons/module/planned.tsx new file mode 100644 index 000000000..97592057c --- /dev/null +++ b/apps/app/components/icons/module/planned.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +type Props = { + width?: string; + height?: string; + className?: string; + color?: string; +}; + +export const ModulePlannedIcon: React.FC<Props> = ({ width = "20", height = "20", className }) => ( + <svg + width={width} + height={height} + className={className} + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M8.57177 7.43329L11.3665 10.228C11.4883 10.3498 11.5441 10.4809 11.5339 10.6213C11.5238 10.7617 11.4578 10.8928 11.336 11.0146C11.2142 11.1364 11.0794 11.1973 10.9317 11.1973C10.784 11.1973 10.6492 11.1364 10.5274 11.0146L7.64476 8.12349C7.57709 8.05582 7.52408 7.98139 7.48574 7.90018C7.4474 7.81898 7.42823 7.72538 7.42823 7.61936V3.51362C7.42823 3.35574 7.48405 3.22097 7.5957 3.10932C7.70734 2.99768 7.84211 2.94185 8 2.94185C8.15789 2.94185 8.29266 2.99768 8.4043 3.10932C8.51595 3.22097 8.57177 3.35574 8.57177 3.51362V7.43329ZM0.806954 11.4933C0.573486 11.04 0.390212 10.5655 0.257131 10.0698C0.124064 9.57411 0.0383541 9.07477 0 8.57177H1.15709C1.18077 8.98793 1.24646 9.38746 1.35418 9.77036C1.46188 10.1533 1.60427 10.5297 1.78133 10.8996L0.806954 11.4933ZM0.021992 7.42823C0.0603462 6.92523 0.143806 6.4273 0.272371 5.93444C0.400937 5.4416 0.579131 4.96567 0.806954 4.50665L1.80333 5.07842C1.62062 5.44834 1.47315 5.82983 1.36093 6.22287C1.24872 6.61591 1.18077 7.0177 1.15709 7.42823H0.021992ZM3.52381 14.6128C3.10877 14.3286 2.71855 14.0103 2.35315 13.6578C1.98774 13.3054 1.66294 12.9217 1.37872 12.5067L2.3751 11.9044C2.5939 12.2507 2.84879 12.5656 3.13976 12.8492C3.43073 13.1329 3.74934 13.3914 4.09558 13.6249L3.52381 14.6128ZM2.3751 4.09558L1.37872 3.52381C1.66294 3.09411 1.98408 2.70163 2.34215 2.34637C2.70023 1.99112 3.09411 1.67139 3.52381 1.38719L4.09558 2.3751C3.75837 2.60856 3.44568 2.86571 3.15753 3.14653C2.86937 3.42736 2.60856 3.7437 2.3751 4.09558ZM7.42823 16C6.92523 15.9616 6.4273 15.8745 5.93444 15.7386C5.4416 15.6027 4.96567 15.4209 4.50665 15.193L5.07842 14.2187C5.44834 14.4014 5.82983 14.5452 6.22287 14.6501C6.61591 14.7549 7.0177 14.8192 7.42823 14.8429V16ZM5.10042 1.80333L4.50665 0.806954C4.96567 0.579131 5.4416 0.397272 5.93444 0.261376C6.4273 0.125479 6.92523 0.0383541 7.42823 0V1.15709C7.0177 1.18077 6.61957 1.24872 6.23386 1.36093C5.84815 1.47315 5.47034 1.62062 5.10042 1.80333ZM8.57177 16V14.8429C8.9823 14.8192 9.38409 14.7549 9.77713 14.6501C10.1702 14.5452 10.5517 14.4014 10.9216 14.2187L11.4933 15.193C11.0343 15.4209 10.5584 15.6027 10.0656 15.7386C9.5727 15.8745 9.07477 15.9616 8.57177 16ZM10.9216 1.78133C10.5517 1.59862 10.1702 1.45482 9.77713 1.34994C9.38409 1.24505 8.9823 1.18077 8.57177 1.15709V0C9.07477 0.0383541 9.5727 0.125479 10.0656 0.261376C10.5584 0.397272 11.0343 0.579131 11.4933 0.806954L10.9216 1.78133ZM12.4762 14.6128L11.9044 13.6469C12.2563 13.4134 12.5726 13.149 12.8535 12.8535C13.1343 12.558 13.3914 12.2416 13.6249 11.9044L14.6128 12.4982C14.3286 12.9132 14.0052 13.297 13.6426 13.6494C13.28 14.0018 12.8912 14.323 12.4762 14.6128ZM13.6249 4.08713C13.3914 3.74991 13.1306 3.43862 12.8425 3.15328C12.5543 2.86796 12.2416 2.60856 11.9044 2.3751L12.4762 1.38719C12.8912 1.67703 13.28 1.99817 13.6426 2.3506C14.0052 2.70304 14.3314 3.08678 14.6213 3.50181L13.6249 4.08713ZM14.8429 7.42823C14.8192 7.0177 14.7535 6.61957 14.6458 6.23386C14.5381 5.84815 14.3929 5.46752 14.2102 5.09197L15.193 4.50665C15.4265 4.96003 15.6098 5.43314 15.7429 5.926C15.8759 6.41884 15.9616 6.91958 16 7.42823H14.8429ZM15.193 11.4933L14.2187 10.9216C14.4014 10.5517 14.5452 10.1702 14.6501 9.77713C14.7549 9.38409 14.8192 8.9823 14.8429 8.57177H16C15.9616 9.07477 15.8745 9.5727 15.7386 10.0656C15.6027 10.5584 15.4209 11.0343 15.193 11.4933Z" + fill="#3f76ff" + /> + </svg> +); diff --git a/apps/app/components/modules/gantt-chart/blocks.tsx b/apps/app/components/modules/gantt-chart/blocks.tsx index bcf307098..c6400ad82 100644 --- a/apps/app/components/modules/gantt-chart/blocks.tsx +++ b/apps/app/components/modules/gantt-chart/blocks.tsx @@ -2,6 +2,8 @@ import { useRouter } from "next/router"; // ui import { Tooltip } from "components/ui"; +// icons +import { ModuleStatusIcon } from "components/icons"; // helpers import { renderShortDate } from "helpers/date-time.helper"; // types @@ -49,6 +51,7 @@ export const ModuleGanttSidebarBlock = ({ data }: { data: IModule }) => { className="relative w-full flex items-center gap-2 h-full" onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/modules/${data.id}`)} > + <ModuleStatusIcon status={data?.status ?? "backlog"} height="16px" width="16px" /> <h6 className="text-sm font-medium flex-grow truncate">{data.name}</h6> </div> ); diff --git a/apps/app/components/modules/select/status.tsx b/apps/app/components/modules/select/status.tsx index 08192cd5d..8c16ca14c 100644 --- a/apps/app/components/modules/select/status.tsx +++ b/apps/app/components/modules/select/status.tsx @@ -6,6 +6,7 @@ import { Controller, FieldError, Control } from "react-hook-form"; import { CustomSelect } from "components/ui"; // icons import { Squares2X2Icon } from "@heroicons/react/24/outline"; +import { ModuleStatusIcon } from "components/icons"; // types import type { IModule } from "types"; // constants @@ -31,12 +32,7 @@ export const ModuleStatusSelect: React.FC<Props> = ({ control, error }) => ( }`} > {value ? ( - <span - className="h-1.5 w-1.5 flex-shrink-0 rounded-full" - style={{ - backgroundColor: MODULE_STATUS.find((s) => s.value === value)?.color, - }} - /> + <ModuleStatusIcon status={value} /> ) : ( <Squares2X2Icon className={`h-3 w-3 ${error ? "text-red-500" : "text-custom-text-200"}`} @@ -53,12 +49,7 @@ export const ModuleStatusSelect: React.FC<Props> = ({ control, error }) => ( {MODULE_STATUS.map((status) => ( <CustomSelect.Option key={status.value} value={status.value}> <div className="flex items-center gap-2"> - <span - className="h-1.5 w-1.5 flex-shrink-0 rounded-full" - style={{ - backgroundColor: status.color, - }} - /> + <ModuleStatusIcon status={status.value} /> {status.label} </div> </CustomSelect.Option> diff --git a/apps/app/components/modules/sidebar.tsx b/apps/app/components/modules/sidebar.tsx index 0407b95aa..e815d1950 100644 --- a/apps/app/components/modules/sidebar.tsx +++ b/apps/app/components/modules/sidebar.tsx @@ -48,7 +48,7 @@ const defaultValues: Partial<IModule> = { members_list: [], start_date: null, target_date: null, - status: null, + status: "backlog", }; type Props = { diff --git a/apps/app/constants/module.ts b/apps/app/constants/module.ts index ffacdfa3c..058171328 100644 --- a/apps/app/constants/module.ts +++ b/apps/app/constants/module.ts @@ -1,8 +1,15 @@ -export const MODULE_STATUS = [ - { label: "Backlog", value: "backlog", color: "#5e6ad2" }, - { label: "Planned", value: "planned", color: "#26b5ce" }, - { label: "In Progress", value: "in-progress", color: "#f2c94c" }, - { label: "Paused", value: "paused", color: "#ff6900" }, - { label: "Completed", value: "completed", color: "#4cb782" }, - { label: "Cancelled", value: "cancelled", color: "#cc1d10" }, +// types +import { TModuleStatus } from "types"; + +export const MODULE_STATUS: { + label: string; + value: TModuleStatus; + color: string; +}[] = [ + { label: "Backlog", value: "backlog", color: "#a3a3a2" }, + { label: "Planned", value: "planned", color: "#3f76ff" }, + { label: "In Progress", value: "in-progress", color: "#f39e1f" }, + { label: "Paused", value: "paused", color: "#525252" }, + { label: "Completed", value: "completed", color: "#16a34a" }, + { label: "Cancelled", value: "cancelled", color: "#ef4444" }, ]; diff --git a/apps/app/types/modules.d.ts b/apps/app/types/modules.d.ts index e395f6f16..709d1d300 100644 --- a/apps/app/types/modules.d.ts +++ b/apps/app/types/modules.d.ts @@ -10,6 +10,14 @@ import type { linkDetails, } from "types"; +export type TModuleStatus = + | "backlog" + | "planned" + | "in-progress" + | "paused" + | "completed" + | "cancelled"; + export interface IModule { backlog_issues: number; cancelled_issues: number; @@ -39,7 +47,7 @@ export interface IModule { sort_order: number; start_date: string | null; started_issues: number; - status: "backlog" | "planned" | "in-progress" | "paused" | "completed" | "cancelled" | null; + status: TModuleStatus; target_date: string | null; total_issues: number; unstarted_issues: number; From 23f5d5d1726b250937154802e028118b804c74a4 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 30 Aug 2023 12:15:08 +0530 Subject: [PATCH 16/27] chore: track public board comments and reaction users for public deploy boards (#1972) * chore: track project deploy board comment and reaction users for public deploy boards * dev: remove tracking from project viewsets --- apiserver/plane/api/serializers/__init__.py | 1 + apiserver/plane/api/serializers/project.py | 15 +++++++- apiserver/plane/api/views/issue.py | 39 ++++++++++++++++++++- apiserver/plane/db/models/__init__.py | 1 + apiserver/plane/db/models/project.py | 15 ++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 93b21a7f2..2dc910caf 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -20,6 +20,7 @@ from .project import ( ProjectMemberLiteSerializer, ProjectDeployBoardSerializer, ProjectMemberAdminSerializer, + ProjectPublicMemberSerializer ) from .state import StateSerializer, StateLiteSerializer from .view import IssueViewSerializer, IssueViewFavoriteSerializer diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 55847881d..49d986cae 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -15,6 +15,7 @@ from plane.db.models import ( ProjectIdentifier, ProjectFavorite, ProjectDeployBoard, + ProjectPublicMember, ) @@ -177,5 +178,17 @@ class ProjectDeployBoardSerializer(BaseSerializer): fields = "__all__" read_only_fields = [ "workspace", - "project" "anchor", + "project", "anchor", + ] + + +class ProjectPublicMemberSerializer(BaseSerializer): + + class Meta: + model = ProjectPublicMember + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "member", ] diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 05434aec5..802431d2e 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -75,6 +75,7 @@ from plane.db.models import ( CommentReaction, ProjectDeployBoard, IssueVote, + ProjectPublicMember, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -1545,6 +1546,16 @@ class IssueCommentPublicViewSet(BaseViewSet): project_id=str(project_id), current_instance=None, ) + if not ProjectMember.objects.filter( + project_id=project_id, + member=request.user, + ).exists(): + # Add the user for workspace tracking + _ = ProjectPublicMember.objects.get_or_create( + project_id=project_id, + member=request.user, + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except Exception as e: @@ -1671,6 +1682,15 @@ class IssueReactionPublicViewSet(BaseViewSet): serializer.save( project_id=project_id, issue_id=issue_id, actor=request.user ) + if not ProjectMember.objects.filter( + project_id=project_id, + member=request.user, + ).exists(): + # Add the user for workspace tracking + _ = ProjectPublicMember.objects.get_or_create( + project_id=project_id, + member=request.user, + ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except ProjectDeployBoard.DoesNotExist: @@ -1756,6 +1776,14 @@ class CommentReactionPublicViewSet(BaseViewSet): serializer.save( project_id=project_id, comment_id=comment_id, actor=request.user ) + if not ProjectMember.objects.filter( + project_id=project_id, member=request.user + ).exists(): + # Add the user for workspace tracking + _ = ProjectPublicMember.objects.get_or_create( + project_id=project_id, + member=request.user, + ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except ProjectDeployBoard.DoesNotExist: @@ -1823,6 +1851,14 @@ class IssueVotePublicViewSet(BaseViewSet): project_id=project_id, issue_id=issue_id, ) + # Add the user for workspace tracking + if not ProjectMember.objects.filter( + project_id=project_id, member=request.user + ).exists(): + _ = ProjectPublicMember.objects.get_or_create( + project_id=project_id, + member=request.user, + ) issue_vote.vote = request.data.get("vote", 1) issue_vote.save() serializer = IssueVoteSerializer(issue_vote) @@ -2021,4 +2057,5 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, - ) \ No newline at end of file + ) + diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 659eea3eb..90532dc64 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -19,6 +19,7 @@ from .project import ( ProjectIdentifier, ProjectFavorite, ProjectDeployBoard, + ProjectPublicMember, ) from .issue import ( diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 0c2b5cb96..da155af40 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -254,3 +254,18 @@ class ProjectDeployBoard(ProjectBaseModel): def __str__(self): """Return project and anchor""" return f"{self.anchor} <{self.project.name}>" + + +class ProjectPublicMember(ProjectBaseModel): + member = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="public_project_members", + ) + + class Meta: + unique_together = ["project", "member"] + verbose_name = "Project Public Member" + verbose_name_plural = "Project Public Members" + db_table = "project_public_members" + ordering = ("-created_at",) From 426f65898b24856b6c775955167a7322f0c4adf6 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 30 Aug 2023 12:18:18 +0530 Subject: [PATCH 17/27] feat: user timezones (#2009) * dev: user timezones * feat: user timezones --- apiserver/plane/api/views/base.py | 27 +++++++++++--- ..._alter_analyticview_created_by_and_more.py | 35 +++++++++++++++++++ apiserver/plane/db/models/user.py | 7 ++-- apiserver/plane/settings/common.py | 4 +-- 4 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 3c260e03b..60b0ec0c6 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -1,24 +1,41 @@ +# Python imports +import zoneinfo + # Django imports from django.urls import resolve from django.conf import settings - +from django.utils import timezone # Third part imports + from rest_framework import status from rest_framework.viewsets import ModelViewSet from rest_framework.exceptions import APIException from rest_framework.views import APIView from rest_framework.filters import SearchFilter from rest_framework.permissions import IsAuthenticated -from rest_framework.exceptions import NotFound from sentry_sdk import capture_exception from django_filters.rest_framework import DjangoFilterBackend # Module imports -from plane.db.models import Workspace, Project from plane.utils.paginator import BasePaginator -class BaseViewSet(ModelViewSet, BasePaginator): +class TimezoneMixin: + """ + This enables timezone conversion according + to the user set timezone + """ + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if request.user.is_authenticated: + timezone.activate(zoneinfo.ZoneInfo(request.user.user_timezone)) + else: + timezone.deactivate() + + + + +class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): model = None @@ -67,7 +84,7 @@ class BaseViewSet(ModelViewSet, BasePaginator): return self.kwargs.get("pk", None) -class BaseAPIView(APIView, BasePaginator): +class BaseAPIView(TimezoneMixin, APIView, BasePaginator): permission_classes = [ IsAuthenticated, diff --git a/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py new file mode 100644 index 000000000..f7d6a979d --- /dev/null +++ b/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.3 on 2023-08-29 06:26 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def update_user_timezones(apps, schema_editor): + UserModel = apps.get_model("db", "User") + updated_users = [] + for obj in UserModel.objects.all(): + obj.user_timezone = "UTC" + updated_users.append(obj) + UserModel.objects.bulk_update(updated_users, ["user_timezone"], batch_size=100) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0041_cycle_sort_order_issuecomment_access_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='user_timezone', + field=models.CharField(choices=[('Africa/Abidjan', 'Africa/Abidjan'), ('Africa/Accra', 'Africa/Accra'), ('Africa/Addis_Ababa', 'Africa/Addis_Ababa'), ('Africa/Algiers', 'Africa/Algiers'), ('Africa/Asmara', 'Africa/Asmara'), ('Africa/Asmera', 'Africa/Asmera'), ('Africa/Bamako', 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), ('Africa/Banjul', 'Africa/Banjul'), ('Africa/Bissau', 'Africa/Bissau'), ('Africa/Blantyre', 'Africa/Blantyre'), ('Africa/Brazzaville', 'Africa/Brazzaville'), ('Africa/Bujumbura', 'Africa/Bujumbura'), ('Africa/Cairo', 'Africa/Cairo'), ('Africa/Casablanca', 'Africa/Casablanca'), ('Africa/Ceuta', 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), ('Africa/Dakar', 'Africa/Dakar'), ('Africa/Dar_es_Salaam', 'Africa/Dar_es_Salaam'), ('Africa/Djibouti', 'Africa/Djibouti'), ('Africa/Douala', 'Africa/Douala'), ('Africa/El_Aaiun', 'Africa/El_Aaiun'), ('Africa/Freetown', 'Africa/Freetown'), ('Africa/Gaborone', 'Africa/Gaborone'), ('Africa/Harare', 'Africa/Harare'), ('Africa/Johannesburg', 'Africa/Johannesburg'), ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), ('Africa/Khartoum', 'Africa/Khartoum'), ('Africa/Kigali', 'Africa/Kigali'), ('Africa/Kinshasa', 'Africa/Kinshasa'), ('Africa/Lagos', 'Africa/Lagos'), ('Africa/Libreville', 'Africa/Libreville'), ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), ('Africa/Lubumbashi', 'Africa/Lubumbashi'), ('Africa/Lusaka', 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), ('Africa/Maputo', 'Africa/Maputo'), ('Africa/Maseru', 'Africa/Maseru'), ('Africa/Mbabane', 'Africa/Mbabane'), ('Africa/Mogadishu', 'Africa/Mogadishu'), ('Africa/Monrovia', 'Africa/Monrovia'), ('Africa/Nairobi', 'Africa/Nairobi'), ('Africa/Ndjamena', 'Africa/Ndjamena'), ('Africa/Niamey', 'Africa/Niamey'), ('Africa/Nouakchott', 'Africa/Nouakchott'), ('Africa/Ouagadougou', 'Africa/Ouagadougou'), ('Africa/Porto-Novo', 'Africa/Porto-Novo'), ('Africa/Sao_Tome', 'Africa/Sao_Tome'), ('Africa/Timbuktu', 'Africa/Timbuktu'), ('Africa/Tripoli', 'Africa/Tripoli'), ('Africa/Tunis', 'Africa/Tunis'), ('Africa/Windhoek', 'Africa/Windhoek'), ('America/Adak', 'America/Adak'), ('America/Anchorage', 'America/Anchorage'), ('America/Anguilla', 'America/Anguilla'), ('America/Antigua', 'America/Antigua'), ('America/Araguaina', 'America/Araguaina'), ('America/Argentina/Buenos_Aires', 'America/Argentina/Buenos_Aires'), ('America/Argentina/Catamarca', 'America/Argentina/Catamarca'), ('America/Argentina/ComodRivadavia', 'America/Argentina/ComodRivadavia'), ('America/Argentina/Cordoba', 'America/Argentina/Cordoba'), ('America/Argentina/Jujuy', 'America/Argentina/Jujuy'), ('America/Argentina/La_Rioja', 'America/Argentina/La_Rioja'), ('America/Argentina/Mendoza', 'America/Argentina/Mendoza'), ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio_Gallegos'), ('America/Argentina/Salta', 'America/Argentina/Salta'), ('America/Argentina/San_Juan', 'America/Argentina/San_Juan'), ('America/Argentina/San_Luis', 'America/Argentina/San_Luis'), ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), ('America/Aruba', 'America/Aruba'), ('America/Asuncion', 'America/Asuncion'), ('America/Atikokan', 'America/Atikokan'), ('America/Atka', 'America/Atka'), ('America/Bahia', 'America/Bahia'), ('America/Bahia_Banderas', 'America/Bahia_Banderas'), ('America/Barbados', 'America/Barbados'), ('America/Belem', 'America/Belem'), ('America/Belize', 'America/Belize'), ('America/Blanc-Sablon', 'America/Blanc-Sablon'), ('America/Boa_Vista', 'America/Boa_Vista'), ('America/Bogota', 'America/Bogota'), ('America/Boise', 'America/Boise'), ('America/Buenos_Aires', 'America/Buenos_Aires'), ('America/Cambridge_Bay', 'America/Cambridge_Bay'), ('America/Campo_Grande', 'America/Campo_Grande'), ('America/Cancun', 'America/Cancun'), ('America/Caracas', 'America/Caracas'), ('America/Catamarca', 'America/Catamarca'), ('America/Cayenne', 'America/Cayenne'), ('America/Cayman', 'America/Cayman'), ('America/Chicago', 'America/Chicago'), ('America/Chihuahua', 'America/Chihuahua'), ('America/Ciudad_Juarez', 'America/Ciudad_Juarez'), ('America/Coral_Harbour', 'America/Coral_Harbour'), ('America/Cordoba', 'America/Cordoba'), ('America/Costa_Rica', 'America/Costa_Rica'), ('America/Creston', 'America/Creston'), ('America/Cuiaba', 'America/Cuiaba'), ('America/Curacao', 'America/Curacao'), ('America/Danmarkshavn', 'America/Danmarkshavn'), ('America/Dawson', 'America/Dawson'), ('America/Dawson_Creek', 'America/Dawson_Creek'), ('America/Denver', 'America/Denver'), ('America/Detroit', 'America/Detroit'), ('America/Dominica', 'America/Dominica'), ('America/Edmonton', 'America/Edmonton'), ('America/Eirunepe', 'America/Eirunepe'), ('America/El_Salvador', 'America/El_Salvador'), ('America/Ensenada', 'America/Ensenada'), ('America/Fort_Nelson', 'America/Fort_Nelson'), ('America/Fort_Wayne', 'America/Fort_Wayne'), ('America/Fortaleza', 'America/Fortaleza'), ('America/Glace_Bay', 'America/Glace_Bay'), ('America/Godthab', 'America/Godthab'), ('America/Goose_Bay', 'America/Goose_Bay'), ('America/Grand_Turk', 'America/Grand_Turk'), ('America/Grenada', 'America/Grenada'), ('America/Guadeloupe', 'America/Guadeloupe'), ('America/Guatemala', 'America/Guatemala'), ('America/Guayaquil', 'America/Guayaquil'), ('America/Guyana', 'America/Guyana'), ('America/Halifax', 'America/Halifax'), ('America/Havana', 'America/Havana'), ('America/Hermosillo', 'America/Hermosillo'), ('America/Indiana/Indianapolis', 'America/Indiana/Indianapolis'), ('America/Indiana/Knox', 'America/Indiana/Knox'), ('America/Indiana/Marengo', 'America/Indiana/Marengo'), ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), ('America/Indiana/Tell_City', 'America/Indiana/Tell_City'), ('America/Indiana/Vevay', 'America/Indiana/Vevay'), ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), ('America/Indiana/Winamac', 'America/Indiana/Winamac'), ('America/Indianapolis', 'America/Indianapolis'), ('America/Inuvik', 'America/Inuvik'), ('America/Iqaluit', 'America/Iqaluit'), ('America/Jamaica', 'America/Jamaica'), ('America/Jujuy', 'America/Jujuy'), ('America/Juneau', 'America/Juneau'), ('America/Kentucky/Louisville', 'America/Kentucky/Louisville'), ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), ('America/Knox_IN', 'America/Knox_IN'), ('America/Kralendijk', 'America/Kralendijk'), ('America/La_Paz', 'America/La_Paz'), ('America/Lima', 'America/Lima'), ('America/Los_Angeles', 'America/Los_Angeles'), ('America/Louisville', 'America/Louisville'), ('America/Lower_Princes', 'America/Lower_Princes'), ('America/Maceio', 'America/Maceio'), ('America/Managua', 'America/Managua'), ('America/Manaus', 'America/Manaus'), ('America/Marigot', 'America/Marigot'), ('America/Martinique', 'America/Martinique'), ('America/Matamoros', 'America/Matamoros'), ('America/Mazatlan', 'America/Mazatlan'), ('America/Mendoza', 'America/Mendoza'), ('America/Menominee', 'America/Menominee'), ('America/Merida', 'America/Merida'), ('America/Metlakatla', 'America/Metlakatla'), ('America/Mexico_City', 'America/Mexico_City'), ('America/Miquelon', 'America/Miquelon'), ('America/Moncton', 'America/Moncton'), ('America/Monterrey', 'America/Monterrey'), ('America/Montevideo', 'America/Montevideo'), ('America/Montreal', 'America/Montreal'), ('America/Montserrat', 'America/Montserrat'), ('America/Nassau', 'America/Nassau'), ('America/New_York', 'America/New_York'), ('America/Nipigon', 'America/Nipigon'), ('America/Nome', 'America/Nome'), ('America/Noronha', 'America/Noronha'), ('America/North_Dakota/Beulah', 'America/North_Dakota/Beulah'), ('America/North_Dakota/Center', 'America/North_Dakota/Center'), ('America/North_Dakota/New_Salem', 'America/North_Dakota/New_Salem'), ('America/Nuuk', 'America/Nuuk'), ('America/Ojinaga', 'America/Ojinaga'), ('America/Panama', 'America/Panama'), ('America/Pangnirtung', 'America/Pangnirtung'), ('America/Paramaribo', 'America/Paramaribo'), ('America/Phoenix', 'America/Phoenix'), ('America/Port-au-Prince', 'America/Port-au-Prince'), ('America/Port_of_Spain', 'America/Port_of_Spain'), ('America/Porto_Acre', 'America/Porto_Acre'), ('America/Porto_Velho', 'America/Porto_Velho'), ('America/Puerto_Rico', 'America/Puerto_Rico'), ('America/Punta_Arenas', 'America/Punta_Arenas'), ('America/Rainy_River', 'America/Rainy_River'), ('America/Rankin_Inlet', 'America/Rankin_Inlet'), ('America/Recife', 'America/Recife'), ('America/Regina', 'America/Regina'), ('America/Resolute', 'America/Resolute'), ('America/Rio_Branco', 'America/Rio_Branco'), ('America/Rosario', 'America/Rosario'), ('America/Santa_Isabel', 'America/Santa_Isabel'), ('America/Santarem', 'America/Santarem'), ('America/Santiago', 'America/Santiago'), ('America/Santo_Domingo', 'America/Santo_Domingo'), ('America/Sao_Paulo', 'America/Sao_Paulo'), ('America/Scoresbysund', 'America/Scoresbysund'), ('America/Shiprock', 'America/Shiprock'), ('America/Sitka', 'America/Sitka'), ('America/St_Barthelemy', 'America/St_Barthelemy'), ('America/St_Johns', 'America/St_Johns'), ('America/St_Kitts', 'America/St_Kitts'), ('America/St_Lucia', 'America/St_Lucia'), ('America/St_Thomas', 'America/St_Thomas'), ('America/St_Vincent', 'America/St_Vincent'), ('America/Swift_Current', 'America/Swift_Current'), ('America/Tegucigalpa', 'America/Tegucigalpa'), ('America/Thule', 'America/Thule'), ('America/Thunder_Bay', 'America/Thunder_Bay'), ('America/Tijuana', 'America/Tijuana'), ('America/Toronto', 'America/Toronto'), ('America/Tortola', 'America/Tortola'), ('America/Vancouver', 'America/Vancouver'), ('America/Virgin', 'America/Virgin'), ('America/Whitehorse', 'America/Whitehorse'), ('America/Winnipeg', 'America/Winnipeg'), ('America/Yakutat', 'America/Yakutat'), ('America/Yellowknife', 'America/Yellowknife'), ('Antarctica/Casey', 'Antarctica/Casey'), ('Antarctica/Davis', 'Antarctica/Davis'), ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), ('Antarctica/Macquarie', 'Antarctica/Macquarie'), ('Antarctica/Mawson', 'Antarctica/Mawson'), ('Antarctica/McMurdo', 'Antarctica/McMurdo'), ('Antarctica/Palmer', 'Antarctica/Palmer'), ('Antarctica/Rothera', 'Antarctica/Rothera'), ('Antarctica/South_Pole', 'Antarctica/South_Pole'), ('Antarctica/Syowa', 'Antarctica/Syowa'), ('Antarctica/Troll', 'Antarctica/Troll'), ('Antarctica/Vostok', 'Antarctica/Vostok'), ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), ('Asia/Ashgabat', 'Asia/Ashgabat'), ('Asia/Ashkhabad', 'Asia/Ashkhabad'), ('Asia/Atyrau', 'Asia/Atyrau'), ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), ('Asia/Calcutta', 'Asia/Calcutta'), ('Asia/Chita', 'Asia/Chita'), ('Asia/Choibalsan', 'Asia/Choibalsan'), ('Asia/Chongqing', 'Asia/Chongqing'), ('Asia/Chungking', 'Asia/Chungking'), ('Asia/Colombo', 'Asia/Colombo'), ('Asia/Dacca', 'Asia/Dacca'), ('Asia/Damascus', 'Asia/Damascus'), ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), ('Asia/Harbin', 'Asia/Harbin'), ('Asia/Hebron', 'Asia/Hebron'), ('Asia/Ho_Chi_Minh', 'Asia/Ho_Chi_Minh'), ('Asia/Hong_Kong', 'Asia/Hong_Kong'), ('Asia/Hovd', 'Asia/Hovd'), ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Istanbul', 'Asia/Istanbul'), ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), ('Asia/Kamchatka', 'Asia/Kamchatka'), ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Kashgar', 'Asia/Kashgar'), ('Asia/Kathmandu', 'Asia/Kathmandu'), ('Asia/Katmandu', 'Asia/Katmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), ('Asia/Kolkata', 'Asia/Kolkata'), ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), ('Asia/Kuala_Lumpur', 'Asia/Kuala_Lumpur'), ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), ('Asia/Macao', 'Asia/Macao'), ('Asia/Macau', 'Asia/Macau'), ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), ('Asia/Nicosia', 'Asia/Nicosia'), ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom_Penh'), ('Asia/Pontianak', 'Asia/Pontianak'), ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), ('Asia/Qostanay', 'Asia/Qostanay'), ('Asia/Qyzylorda', 'Asia/Qyzylorda'), ('Asia/Rangoon', 'Asia/Rangoon'), ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Saigon', 'Asia/Saigon'), ('Asia/Sakhalin', 'Asia/Sakhalin'), ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), ('Asia/Shanghai', 'Asia/Shanghai'), ('Asia/Singapore', 'Asia/Singapore'), ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), ('Asia/Tel_Aviv', 'Asia/Tel_Aviv'), ('Asia/Thimbu', 'Asia/Thimbu'), ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), ('Asia/Tomsk', 'Asia/Tomsk'), ('Asia/Ujung_Pandang', 'Asia/Ujung_Pandang'), ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), ('Asia/Ulan_Bator', 'Asia/Ulan_Bator'), ('Asia/Urumqi', 'Asia/Urumqi'), ('Asia/Ust-Nera', 'Asia/Ust-Nera'), ('Asia/Vientiane', 'Asia/Vientiane'), ('Asia/Vladivostok', 'Asia/Vladivostok'), ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yangon', 'Asia/Yangon'), ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), ('Asia/Yerevan', 'Asia/Yerevan'), ('Atlantic/Azores', 'Atlantic/Azores'), ('Atlantic/Bermuda', 'Atlantic/Bermuda'), ('Atlantic/Canary', 'Atlantic/Canary'), ('Atlantic/Cape_Verde', 'Atlantic/Cape_Verde'), ('Atlantic/Faeroe', 'Atlantic/Faeroe'), ('Atlantic/Faroe', 'Atlantic/Faroe'), ('Atlantic/Jan_Mayen', 'Atlantic/Jan_Mayen'), ('Atlantic/Madeira', 'Atlantic/Madeira'), ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), ('Atlantic/South_Georgia', 'Atlantic/South_Georgia'), ('Atlantic/St_Helena', 'Atlantic/St_Helena'), ('Atlantic/Stanley', 'Atlantic/Stanley'), ('Australia/ACT', 'Australia/ACT'), ('Australia/Adelaide', 'Australia/Adelaide'), ('Australia/Brisbane', 'Australia/Brisbane'), ('Australia/Broken_Hill', 'Australia/Broken_Hill'), ('Australia/Canberra', 'Australia/Canberra'), ('Australia/Currie', 'Australia/Currie'), ('Australia/Darwin', 'Australia/Darwin'), ('Australia/Eucla', 'Australia/Eucla'), ('Australia/Hobart', 'Australia/Hobart'), ('Australia/LHI', 'Australia/LHI'), ('Australia/Lindeman', 'Australia/Lindeman'), ('Australia/Lord_Howe', 'Australia/Lord_Howe'), ('Australia/Melbourne', 'Australia/Melbourne'), ('Australia/NSW', 'Australia/NSW'), ('Australia/North', 'Australia/North'), ('Australia/Perth', 'Australia/Perth'), ('Australia/Queensland', 'Australia/Queensland'), ('Australia/South', 'Australia/South'), ('Australia/Sydney', 'Australia/Sydney'), ('Australia/Tasmania', 'Australia/Tasmania'), ('Australia/Victoria', 'Australia/Victoria'), ('Australia/West', 'Australia/West'), ('Australia/Yancowinna', 'Australia/Yancowinna'), ('Brazil/Acre', 'Brazil/Acre'), ('Brazil/DeNoronha', 'Brazil/DeNoronha'), ('Brazil/East', 'Brazil/East'), ('Brazil/West', 'Brazil/West'), ('CET', 'CET'), ('CST6CDT', 'CST6CDT'), ('Canada/Atlantic', 'Canada/Atlantic'), ('Canada/Central', 'Canada/Central'), ('Canada/Eastern', 'Canada/Eastern'), ('Canada/Mountain', 'Canada/Mountain'), ('Canada/Newfoundland', 'Canada/Newfoundland'), ('Canada/Pacific', 'Canada/Pacific'), ('Canada/Saskatchewan', 'Canada/Saskatchewan'), ('Canada/Yukon', 'Canada/Yukon'), ('Chile/Continental', 'Chile/Continental'), ('Chile/EasterIsland', 'Chile/EasterIsland'), ('Cuba', 'Cuba'), ('EET', 'EET'), ('EST', 'EST'), ('EST5EDT', 'EST5EDT'), ('Egypt', 'Egypt'), ('Eire', 'Eire'), ('Etc/GMT', 'Etc/GMT'), ('Etc/GMT+0', 'Etc/GMT+0'), ('Etc/GMT+1', 'Etc/GMT+1'), ('Etc/GMT+10', 'Etc/GMT+10'), ('Etc/GMT+11', 'Etc/GMT+11'), ('Etc/GMT+12', 'Etc/GMT+12'), ('Etc/GMT+2', 'Etc/GMT+2'), ('Etc/GMT+3', 'Etc/GMT+3'), ('Etc/GMT+4', 'Etc/GMT+4'), ('Etc/GMT+5', 'Etc/GMT+5'), ('Etc/GMT+6', 'Etc/GMT+6'), ('Etc/GMT+7', 'Etc/GMT+7'), ('Etc/GMT+8', 'Etc/GMT+8'), ('Etc/GMT+9', 'Etc/GMT+9'), ('Etc/GMT-0', 'Etc/GMT-0'), ('Etc/GMT-1', 'Etc/GMT-1'), ('Etc/GMT-10', 'Etc/GMT-10'), ('Etc/GMT-11', 'Etc/GMT-11'), ('Etc/GMT-12', 'Etc/GMT-12'), ('Etc/GMT-13', 'Etc/GMT-13'), ('Etc/GMT-14', 'Etc/GMT-14'), ('Etc/GMT-2', 'Etc/GMT-2'), ('Etc/GMT-3', 'Etc/GMT-3'), ('Etc/GMT-4', 'Etc/GMT-4'), ('Etc/GMT-5', 'Etc/GMT-5'), ('Etc/GMT-6', 'Etc/GMT-6'), ('Etc/GMT-7', 'Etc/GMT-7'), ('Etc/GMT-8', 'Etc/GMT-8'), ('Etc/GMT-9', 'Etc/GMT-9'), ('Etc/GMT0', 'Etc/GMT0'), ('Etc/Greenwich', 'Etc/Greenwich'), ('Etc/UCT', 'Etc/UCT'), ('Etc/UTC', 'Etc/UTC'), ('Etc/Universal', 'Etc/Universal'), ('Etc/Zulu', 'Etc/Zulu'), ('Europe/Amsterdam', 'Europe/Amsterdam'), ('Europe/Andorra', 'Europe/Andorra'), ('Europe/Astrakhan', 'Europe/Astrakhan'), ('Europe/Athens', 'Europe/Athens'), ('Europe/Belfast', 'Europe/Belfast'), ('Europe/Belgrade', 'Europe/Belgrade'), ('Europe/Berlin', 'Europe/Berlin'), ('Europe/Bratislava', 'Europe/Bratislava'), ('Europe/Brussels', 'Europe/Brussels'), ('Europe/Bucharest', 'Europe/Bucharest'), ('Europe/Budapest', 'Europe/Budapest'), ('Europe/Busingen', 'Europe/Busingen'), ('Europe/Chisinau', 'Europe/Chisinau'), ('Europe/Copenhagen', 'Europe/Copenhagen'), ('Europe/Dublin', 'Europe/Dublin'), ('Europe/Gibraltar', 'Europe/Gibraltar'), ('Europe/Guernsey', 'Europe/Guernsey'), ('Europe/Helsinki', 'Europe/Helsinki'), ('Europe/Isle_of_Man', 'Europe/Isle_of_Man'), ('Europe/Istanbul', 'Europe/Istanbul'), ('Europe/Jersey', 'Europe/Jersey'), ('Europe/Kaliningrad', 'Europe/Kaliningrad'), ('Europe/Kiev', 'Europe/Kiev'), ('Europe/Kirov', 'Europe/Kirov'), ('Europe/Kyiv', 'Europe/Kyiv'), ('Europe/Lisbon', 'Europe/Lisbon'), ('Europe/Ljubljana', 'Europe/Ljubljana'), ('Europe/London', 'Europe/London'), ('Europe/Luxembourg', 'Europe/Luxembourg'), ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), ('Europe/Mariehamn', 'Europe/Mariehamn'), ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), ('Europe/Moscow', 'Europe/Moscow'), ('Europe/Nicosia', 'Europe/Nicosia'), ('Europe/Oslo', 'Europe/Oslo'), ('Europe/Paris', 'Europe/Paris'), ('Europe/Podgorica', 'Europe/Podgorica'), ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), ('Europe/San_Marino', 'Europe/San_Marino'), ('Europe/Sarajevo', 'Europe/Sarajevo'), ('Europe/Saratov', 'Europe/Saratov'), ('Europe/Simferopol', 'Europe/Simferopol'), ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), ('Europe/Stockholm', 'Europe/Stockholm'), ('Europe/Tallinn', 'Europe/Tallinn'), ('Europe/Tirane', 'Europe/Tirane'), ('Europe/Tiraspol', 'Europe/Tiraspol'), ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), ('Europe/Uzhgorod', 'Europe/Uzhgorod'), ('Europe/Vaduz', 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), ('Europe/Vienna', 'Europe/Vienna'), ('Europe/Vilnius', 'Europe/Vilnius'), ('Europe/Volgograd', 'Europe/Volgograd'), ('Europe/Warsaw', 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), ('Europe/Zaporozhye', 'Europe/Zaporozhye'), ('Europe/Zurich', 'Europe/Zurich'), ('GB', 'GB'), ('GB-Eire', 'GB-Eire'), ('GMT', 'GMT'), ('GMT+0', 'GMT+0'), ('GMT-0', 'GMT-0'), ('GMT0', 'GMT0'), ('Greenwich', 'Greenwich'), ('HST', 'HST'), ('Hongkong', 'Hongkong'), ('Iceland', 'Iceland'), ('Indian/Antananarivo', 'Indian/Antananarivo'), ('Indian/Chagos', 'Indian/Chagos'), ('Indian/Christmas', 'Indian/Christmas'), ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), ('Indian/Kerguelen', 'Indian/Kerguelen'), ('Indian/Mahe', 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), ('Indian/Mauritius', 'Indian/Mauritius'), ('Indian/Mayotte', 'Indian/Mayotte'), ('Indian/Reunion', 'Indian/Reunion'), ('Iran', 'Iran'), ('Israel', 'Israel'), ('Jamaica', 'Jamaica'), ('Japan', 'Japan'), ('Kwajalein', 'Kwajalein'), ('Libya', 'Libya'), ('MET', 'MET'), ('MST', 'MST'), ('MST7MDT', 'MST7MDT'), ('Mexico/BajaNorte', 'Mexico/BajaNorte'), ('Mexico/BajaSur', 'Mexico/BajaSur'), ('Mexico/General', 'Mexico/General'), ('NZ', 'NZ'), ('NZ-CHAT', 'NZ-CHAT'), ('Navajo', 'Navajo'), ('PRC', 'PRC'), ('PST8PDT', 'PST8PDT'), ('Pacific/Apia', 'Pacific/Apia'), ('Pacific/Auckland', 'Pacific/Auckland'), ('Pacific/Bougainville', 'Pacific/Bougainville'), ('Pacific/Chatham', 'Pacific/Chatham'), ('Pacific/Chuuk', 'Pacific/Chuuk'), ('Pacific/Easter', 'Pacific/Easter'), ('Pacific/Efate', 'Pacific/Efate'), ('Pacific/Enderbury', 'Pacific/Enderbury'), ('Pacific/Fakaofo', 'Pacific/Fakaofo'), ('Pacific/Fiji', 'Pacific/Fiji'), ('Pacific/Funafuti', 'Pacific/Funafuti'), ('Pacific/Galapagos', 'Pacific/Galapagos'), ('Pacific/Gambier', 'Pacific/Gambier'), ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), ('Pacific/Guam', 'Pacific/Guam'), ('Pacific/Honolulu', 'Pacific/Honolulu'), ('Pacific/Johnston', 'Pacific/Johnston'), ('Pacific/Kanton', 'Pacific/Kanton'), ('Pacific/Kiritimati', 'Pacific/Kiritimati'), ('Pacific/Kosrae', 'Pacific/Kosrae'), ('Pacific/Kwajalein', 'Pacific/Kwajalein'), ('Pacific/Majuro', 'Pacific/Majuro'), ('Pacific/Marquesas', 'Pacific/Marquesas'), ('Pacific/Midway', 'Pacific/Midway'), ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), ('Pacific/Norfolk', 'Pacific/Norfolk'), ('Pacific/Noumea', 'Pacific/Noumea'), ('Pacific/Pago_Pago', 'Pacific/Pago_Pago'), ('Pacific/Palau', 'Pacific/Palau'), ('Pacific/Pitcairn', 'Pacific/Pitcairn'), ('Pacific/Pohnpei', 'Pacific/Pohnpei'), ('Pacific/Ponape', 'Pacific/Ponape'), ('Pacific/Port_Moresby', 'Pacific/Port_Moresby'), ('Pacific/Rarotonga', 'Pacific/Rarotonga'), ('Pacific/Saipan', 'Pacific/Saipan'), ('Pacific/Samoa', 'Pacific/Samoa'), ('Pacific/Tahiti', 'Pacific/Tahiti'), ('Pacific/Tarawa', 'Pacific/Tarawa'), ('Pacific/Tongatapu', 'Pacific/Tongatapu'), ('Pacific/Truk', 'Pacific/Truk'), ('Pacific/Wake', 'Pacific/Wake'), ('Pacific/Wallis', 'Pacific/Wallis'), ('Pacific/Yap', 'Pacific/Yap'), ('Poland', 'Poland'), ('Portugal', 'Portugal'), ('ROC', 'ROC'), ('ROK', 'ROK'), ('Singapore', 'Singapore'), ('Turkey', 'Turkey'), ('UCT', 'UCT'), ('US/Alaska', 'US/Alaska'), ('US/Aleutian', 'US/Aleutian'), ('US/Arizona', 'US/Arizona'), ('US/Central', 'US/Central'), ('US/East-Indiana', 'US/East-Indiana'), ('US/Eastern', 'US/Eastern'), ('US/Hawaii', 'US/Hawaii'), ('US/Indiana-Starke', 'US/Indiana-Starke'), ('US/Michigan', 'US/Michigan'), ('US/Mountain', 'US/Mountain'), ('US/Pacific', 'US/Pacific'), ('US/Samoa', 'US/Samoa'), ('UTC', 'UTC'), ('Universal', 'Universal'), ('W-SU', 'W-SU'), ('WET', 'WET'), ('Zulu', 'Zulu')], default='UTC', max_length=255), + ), + migrations.AlterField( + model_name='issuelink', + name='title', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.RunPython(update_user_timezones) + ] diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 3975a3b93..e90e19c5e 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -2,6 +2,7 @@ import uuid import string import random +import pytz # Django imports from django.db import models @@ -9,9 +10,6 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.contrib.auth.models import AbstractBaseUser, UserManager, PermissionsMixin from django.utils import timezone -from django.core.mail import EmailMultiAlternatives -from django.template.loader import render_to_string -from django.utils.html import strip_tags from django.conf import settings # Third party imports @@ -66,7 +64,8 @@ class User(AbstractBaseUser, PermissionsMixin): billing_address = models.JSONField(null=True) has_billing_address = models.BooleanField(default=False) - user_timezone = models.CharField(max_length=255, default="Asia/Kolkata") + USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + user_timezone = models.CharField(max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES) last_active = models.DateTimeField(default=timezone.now, null=True) last_login_time = models.DateTimeField(null=True) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 59e0bd31b..27da44d9c 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -49,7 +49,7 @@ MIDDLEWARE = [ "django.middleware.clickjacking.XFrameOptionsMiddleware", "crum.CurrentRequestUserMiddleware", "django.middleware.gzip.GZipMiddleware", -] + ] REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( @@ -161,7 +161,7 @@ MEDIA_URL = "/media/" LANGUAGE_CODE = "en-us" -TIME_ZONE = "Asia/Kolkata" +TIME_ZONE = "UTC" USE_I18N = True From 761a1eb41a68dc0479a715b9659521212c2c9a0e Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 30 Aug 2023 12:18:56 +0530 Subject: [PATCH 18/27] fix: user created by stats (#2016) --- apiserver/plane/api/views/workspace.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index cfdd0dd9b..b10fe3d42 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -1100,7 +1100,6 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): created_issues = ( Issue.issue_objects.filter( workspace__slug=slug, - assignees__in=[user_id], project__project_projectmember__member=request.user, created_by_id=user_id, ) From 17aff1f369e15eeb0b5142a732208e964819665b Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Wed, 30 Aug 2023 12:20:13 +0530 Subject: [PATCH 19/27] fix: asset key validation (#1938) * fix: asset key validation * chore: asset key validation in user assets --------- Co-authored-by: Bavisetti Narayan <narayan@Bavisettis-MacBook-Pro.local> --- apiserver/plane/api/views/asset.py | 32 ++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/apiserver/plane/api/views/asset.py b/apiserver/plane/api/views/asset.py index 0b935a4d3..d9b6e502d 100644 --- a/apiserver/plane/api/views/asset.py +++ b/apiserver/plane/api/views/asset.py @@ -18,10 +18,21 @@ class FileAssetEndpoint(BaseAPIView): """ def get(self, request, workspace_id, asset_key): - asset_key = str(workspace_id) + "/" + asset_key - files = FileAsset.objects.filter(asset=asset_key) - serializer = FileAssetSerializer(files, context={"request": request}, many=True) - return Response(serializer.data) + try: + asset_key = str(workspace_id) + "/" + asset_key + files = FileAsset.objects.filter(asset=asset_key) + if files.exists(): + serializer = FileAssetSerializer(files, context={"request": request}, many=True) + return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK) + else: + return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + def post(self, request, slug): try: @@ -68,11 +79,16 @@ class UserAssetsEndpoint(BaseAPIView): def get(self, request, asset_key): try: files = FileAsset.objects.filter(asset=asset_key, created_by=request.user) - serializer = FileAssetSerializer(files, context={"request": request}) - return Response(serializer.data) - except FileAsset.DoesNotExist: + if files.exists(): + serializer = FileAssetSerializer(files, context={"request": request}) + return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK) + else: + return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) return Response( - {"error": "File Asset does not exist"}, status=status.HTTP_404_NOT_FOUND + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, ) def post(self, request): From f5a076e9a9e003f52bebd407765f8da7d4f268da Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 30 Aug 2023 13:26:28 +0530 Subject: [PATCH 20/27] dev: revamp peek overview (#2021) * dev: mobx for issues store * refactor: peek overview component * chore: update open issue button * fix: issue mutation after any crud action * chore: remove peek overview from gantt * chore: refactor code --- .../components/command-palette/command-k.tsx | 2 +- .../views/spreadsheet-view/single-issue.tsx | 24 +- .../spreadsheet-view/spreadsheet-view.tsx | 159 +++++------ .../components/issues/gantt-chart/blocks.tsx | 4 +- .../peek-overview/full-screen-peek-view.tsx | 84 +++--- .../issues/peek-overview/header.tsx | 32 +-- .../issues/peek-overview/issue-properties.tsx | 25 +- .../issues/peek-overview/layout.tsx | 247 ++++++++++++------ .../issues/peek-overview/side-peek-view.tsx | 83 +++--- .../app/hooks/use-spreadsheet-issues-view.tsx | 15 +- apps/app/store/issues.ts | 172 ++++++++++++ apps/app/store/root.ts | 3 + 12 files changed, 576 insertions(+), 274 deletions(-) create mode 100644 apps/app/store/issues.ts diff --git a/apps/app/components/command-palette/command-k.tsx b/apps/app/components/command-palette/command-k.tsx index a1525a348..d20a44290 100644 --- a/apps/app/components/command-palette/command-k.tsx +++ b/apps/app/components/command-palette/command-k.tsx @@ -665,7 +665,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal className="focus:outline-none" > <div className="flex items-center gap-2 text-custom-text-200"> - <DiscordIcon className="h-4 w-4" color="#6b7280" /> + <DiscordIcon className="h-4 w-4" color="rgb(var(--color-text-200))" /> Join our Discord </div> </Command.Item> diff --git a/apps/app/components/core/views/spreadsheet-view/single-issue.tsx b/apps/app/components/core/views/spreadsheet-view/single-issue.tsx index 11a8c42c5..53869a638 100644 --- a/apps/app/components/core/views/spreadsheet-view/single-issue.tsx +++ b/apps/app/components/core/views/spreadsheet-view/single-issue.tsx @@ -6,7 +6,6 @@ import { mutate } from "swr"; // components import { - IssuePeekOverview, ViewAssigneeSelect, ViewDueDateSelect, ViewEstimateSelect, @@ -76,9 +75,6 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({ }) => { const [isOpen, setIsOpen] = useState(false); - // issue peek overview - const [issuePeekOverview, setIssuePeekOverview] = useState(false); - const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; @@ -161,6 +157,15 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({ [workspaceSlug, projectId, cycleId, moduleId, viewId, params, user] ); + const openPeekOverview = () => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: issue.id }, + }); + }; + const handleCopyText = () => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; @@ -183,15 +188,6 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({ return ( <> - <IssuePeekOverview - handleDeleteIssue={() => handleDeleteIssue(issue)} - handleUpdateIssue={async (formData) => partialUpdateIssue(formData, issue)} - issue={issue} - isOpen={issuePeekOverview} - onClose={() => setIssuePeekOverview(false)} - workspaceSlug={workspaceSlug?.toString() ?? ""} - readOnly={isNotAllowed} - /> <div className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max" style={{ gridTemplateColumns }} @@ -280,7 +276,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({ <button type="button" className="truncate text-custom-text-100 text-left cursor-pointer w-full text-[0.825rem]" - onClick={() => setIssuePeekOverview(true)} + onClick={openPeekOverview} > {issue.name} </button> diff --git a/apps/app/components/core/views/spreadsheet-view/spreadsheet-view.tsx b/apps/app/components/core/views/spreadsheet-view/spreadsheet-view.tsx index 0b2e785d6..1076f30d0 100644 --- a/apps/app/components/core/views/spreadsheet-view/spreadsheet-view.tsx +++ b/apps/app/components/core/views/spreadsheet-view/spreadsheet-view.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/router"; // components import { SpreadsheetColumns, SpreadsheetIssues } from "components/core"; import { CustomMenu, Spinner } from "components/ui"; +import { IssuePeekOverview } from "components/issues"; // hooks import useIssuesProperties from "hooks/use-issue-properties"; import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; @@ -38,7 +39,7 @@ export const SpreadsheetView: React.FC<Props> = ({ const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; - const { spreadsheetIssues } = useSpreadsheetIssuesView(); + const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView(); const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); @@ -59,80 +60,88 @@ export const SpreadsheetView: React.FC<Props> = ({ .join(" "); return ( - <div className="h-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-100"> - <div className="sticky z-[2] top-0 border-b border-custom-border-200 bg-custom-background-90 w-full min-w-max"> - <SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} /> - </div> - {spreadsheetIssues ? ( - <div className="flex flex-col h-full w-full bg-custom-background-100 rounded-sm "> - {spreadsheetIssues.map((issue: IIssue, index) => ( - <SpreadsheetIssues - key={`${issue.id}_${index}`} - index={index} - issue={issue} - expandedIssues={expandedIssues} - setExpandedIssues={setExpandedIssues} - gridTemplateColumns={gridTemplateColumns} - properties={properties} - handleIssueAction={handleIssueAction} - disableUserActions={disableUserActions} - user={user} - userAuth={userAuth} - /> - ))} - <div - className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max" - style={{ gridTemplateColumns }} - > - {type === "issue" ? ( - <button - className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full" - onClick={() => { - const e = new KeyboardEvent("keydown", { key: "c" }); - document.dispatchEvent(e); - }} - > - <PlusIcon className="h-4 w-4" /> - Add Issue - </button> - ) : ( - !disableUserActions && ( - <CustomMenu - className="sticky left-0 z-[1]" - customButton={ - <button - className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full" - type="button" - > - <PlusIcon className="h-4 w-4" /> - Add Issue - </button> - } - position="left" - optionsClassName="left-5 !w-36" - noBorder - > - <CustomMenu.MenuItem - onClick={() => { - const e = new KeyboardEvent("keydown", { key: "c" }); - document.dispatchEvent(e); - }} - > - Create new - </CustomMenu.MenuItem> - {openIssuesListModal && ( - <CustomMenu.MenuItem onClick={openIssuesListModal}> - Add an existing issue - </CustomMenu.MenuItem> - )} - </CustomMenu> - ) - )} - </div> + <> + <IssuePeekOverview + handleMutation={() => mutateIssues()} + projectId={projectId?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} + /> + <div className="h-full rounded-lg text-custom-text-200 overflow-x-auto whitespace-nowrap bg-custom-background-100"> + <div className="sticky z-[2] top-0 border-b border-custom-border-200 bg-custom-background-90 w-full min-w-max"> + <SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} /> </div> - ) : ( - <Spinner /> - )} - </div> + {spreadsheetIssues ? ( + <div className="flex flex-col h-full w-full bg-custom-background-100 rounded-sm "> + {spreadsheetIssues.map((issue: IIssue, index) => ( + <SpreadsheetIssues + key={`${issue.id}_${index}`} + index={index} + issue={issue} + expandedIssues={expandedIssues} + setExpandedIssues={setExpandedIssues} + gridTemplateColumns={gridTemplateColumns} + properties={properties} + handleIssueAction={handleIssueAction} + disableUserActions={disableUserActions} + user={user} + userAuth={userAuth} + /> + ))} + <div + className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-custom-background-80 border-b border-custom-border-200 w-full min-w-max" + style={{ gridTemplateColumns }} + > + {type === "issue" ? ( + <button + className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full" + onClick={() => { + const e = new KeyboardEvent("keydown", { key: "c" }); + document.dispatchEvent(e); + }} + > + <PlusIcon className="h-4 w-4" /> + Add Issue + </button> + ) : ( + !disableUserActions && ( + <CustomMenu + className="sticky left-0 z-[1]" + customButton={ + <button + className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-custom-text-200 bg-custom-background-100 group-hover:text-custom-text-100 group-hover:bg-custom-background-80 border-custom-border-200 w-full" + type="button" + > + <PlusIcon className="h-4 w-4" /> + Add Issue + </button> + } + position="left" + optionsClassName="left-5 !w-36" + noBorder + > + <CustomMenu.MenuItem + onClick={() => { + const e = new KeyboardEvent("keydown", { key: "c" }); + document.dispatchEvent(e); + }} + > + Create new + </CustomMenu.MenuItem> + {openIssuesListModal && ( + <CustomMenu.MenuItem onClick={openIssuesListModal}> + Add an existing issue + </CustomMenu.MenuItem> + )} + </CustomMenu> + ) + )} + </div> + </div> + ) : ( + <Spinner /> + )} + </div> + </> ); }; diff --git a/apps/app/components/issues/gantt-chart/blocks.tsx b/apps/app/components/issues/gantt-chart/blocks.tsx index 2ad21c499..3ab7ea90b 100644 --- a/apps/app/components/issues/gantt-chart/blocks.tsx +++ b/apps/app/components/issues/gantt-chart/blocks.tsx @@ -15,7 +15,7 @@ export const IssueGanttBlock = ({ data }: { data: IIssue }) => { return ( <div - className="flex items-center relative h-full w-full rounded" + className="flex items-center relative h-full w-full rounded cursor-pointer" style={{ backgroundColor: data?.state_detail?.color }} onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)} > @@ -49,7 +49,7 @@ export const IssueGanttSidebarBlock = ({ data }: { data: IIssue }) => { return ( <div - className="relative w-full flex items-center gap-2 h-full" + className="relative w-full flex items-center gap-2 h-full cursor-pointer" onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)} > {getStateGroupIcon(data?.state_detail?.group, "14", "14", data?.state_detail?.color)} diff --git a/apps/app/components/issues/peek-overview/full-screen-peek-view.tsx b/apps/app/components/issues/peek-overview/full-screen-peek-view.tsx index d470f4910..9c04e0b6a 100644 --- a/apps/app/components/issues/peek-overview/full-screen-peek-view.tsx +++ b/apps/app/components/issues/peek-overview/full-screen-peek-view.tsx @@ -1,3 +1,4 @@ +// components import { PeekOverviewHeader, PeekOverviewIssueActivity, @@ -5,13 +6,16 @@ import { PeekOverviewIssueProperties, TPeekOverviewModes, } from "components/issues"; +// ui +import { Loader } from "components/ui"; +// types import { IIssue } from "types"; type Props = { handleClose: () => void; handleDeleteIssue: () => void; - handleUpdateIssue: (issue: Partial<IIssue>) => Promise<void>; - issue: IIssue; + handleUpdateIssue: (formData: Partial<IIssue>) => Promise<void>; + issue: IIssue | undefined; mode: TPeekOverviewModes; readOnly: boolean; setMode: (mode: TPeekOverviewModes) => void; @@ -40,39 +44,59 @@ export const FullScreenPeekView: React.FC<Props> = ({ workspaceSlug={workspaceSlug} /> </div> - <div className="h-full w-full px-6 overflow-y-auto"> - {/* issue title and description */} - <div className="w-full"> - <PeekOverviewIssueDetails - handleUpdateIssue={handleUpdateIssue} - issue={issue} - readOnly={readOnly} - workspaceSlug={workspaceSlug} - /> + {issue ? ( + <div className="h-full w-full px-6 overflow-y-auto"> + {/* issue title and description */} + <div className="w-full"> + <PeekOverviewIssueDetails + handleUpdateIssue={handleUpdateIssue} + issue={issue} + readOnly={readOnly} + workspaceSlug={workspaceSlug} + /> + </div> + {/* divider */} + <div className="h-[1] w-full border-t border-custom-border-200 my-5" /> + {/* issue activity/comments */} + <div className="w-full pb-5"> + <PeekOverviewIssueActivity + workspaceSlug={workspaceSlug} + issue={issue} + readOnly={readOnly} + /> + </div> </div> - {/* divider */} - <div className="h-[1] w-full border-t border-custom-border-200 my-5" /> - {/* issue activity/comments */} - <div className="w-full"> - <PeekOverviewIssueActivity - workspaceSlug={workspaceSlug} - issue={issue} - readOnly={readOnly} - /> - </div> - </div> + ) : ( + <Loader className="px-6"> + <Loader.Item height="30px" /> + <div className="space-y-2 mt-3"> + <Loader.Item height="20px" width="70%" /> + <Loader.Item height="20px" width="60%" /> + <Loader.Item height="20px" width="60%" /> + </div> + </Loader> + )} </div> <div className="col-span-3 h-full w-full overflow-y-auto"> {/* issue properties */} <div className="w-full px-6 py-5"> - <PeekOverviewIssueProperties - handleDeleteIssue={handleDeleteIssue} - issue={issue} - mode="full" - onChange={handleUpdateIssue} - readOnly={readOnly} - workspaceSlug={workspaceSlug} - /> + {issue ? ( + <PeekOverviewIssueProperties + handleDeleteIssue={handleDeleteIssue} + handleUpdateIssue={handleUpdateIssue} + issue={issue} + mode="full" + readOnly={readOnly} + workspaceSlug={workspaceSlug} + /> + ) : ( + <Loader className="mt-11 space-y-4"> + <Loader.Item height="30px" /> + <Loader.Item height="30px" /> + <Loader.Item height="30px" /> + <Loader.Item height="30px" /> + </Loader> + )} </div> </div> </div> diff --git a/apps/app/components/issues/peek-overview/header.tsx b/apps/app/components/issues/peek-overview/header.tsx index 29e23a262..266b2edb8 100644 --- a/apps/app/components/issues/peek-overview/header.tsx +++ b/apps/app/components/issues/peek-overview/header.tsx @@ -1,18 +1,21 @@ +import Link from "next/link"; + // hooks import useToast from "hooks/use-toast"; // ui import { CustomSelect, Icon } from "components/ui"; +// icons +import { East, OpenInFull } from "@mui/icons-material"; // helpers import { copyTextToClipboard } from "helpers/string.helper"; // types import { IIssue } from "types"; import { TPeekOverviewModes } from "./layout"; -import { ArrowRightAlt, CloseFullscreen, East, OpenInFull } from "@mui/icons-material"; type Props = { handleClose: () => void; handleDeleteIssue: () => void; - issue: IIssue; + issue: IIssue | undefined; mode: TPeekOverviewModes; setMode: (mode: TPeekOverviewModes) => void; workspaceSlug: string; @@ -47,12 +50,9 @@ export const PeekOverviewHeader: React.FC<Props> = ({ const { setToastAlert } = useToast(); const handleCopyLink = () => { - const originURL = - typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + const urlToCopy = window.location.href; - copyTextToClipboard( - `${originURL}/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}` - ).then(() => { + copyTextToClipboard(urlToCopy).then(() => { setToastAlert({ type: "success", title: "Link copied!", @@ -73,23 +73,15 @@ export const PeekOverviewHeader: React.FC<Props> = ({ /> </button> )} - {mode === "modal" || mode === "full" ? ( - <button type="button" onClick={() => setMode("side")}> - <CloseFullscreen - sx={{ - fontSize: "14px", - }} - /> - </button> - ) : ( - <button type="button" onClick={() => setMode("modal")}> + <Link href={`/${workspaceSlug}/projects/${issue?.project}/issues/${issue?.id}`}> + <a> <OpenInFull sx={{ fontSize: "14px", }} /> - </button> - )} + </a> + </Link> <CustomSelect value={mode} onChange={(val: TPeekOverviewModes) => setMode(val)} @@ -119,7 +111,7 @@ export const PeekOverviewHeader: React.FC<Props> = ({ </CustomSelect> </div> {(mode === "side" || mode === "modal") && ( - <div className="flex items-center gap-2"> + <div className="flex items-center gap-2 flex-shrink-0"> <button type="button" onClick={handleCopyLink} className="-rotate-45"> <Icon iconName="link" /> </button> diff --git a/apps/app/components/issues/peek-overview/issue-properties.tsx b/apps/app/components/issues/peek-overview/issue-properties.tsx index bf1ecefd9..2c8b4d572 100644 --- a/apps/app/components/issues/peek-overview/issue-properties.tsx +++ b/apps/app/components/issues/peek-overview/issue-properties.tsx @@ -1,6 +1,11 @@ +// mobx +import { observer } from "mobx-react-lite"; // headless ui import { Disclosure } from "@headlessui/react"; import { getStateGroupIcon } from "components/icons"; +// hooks +import useToast from "hooks/use-toast"; +import useUser from "hooks/use-user"; // components import { SidebarAssigneeSelect, @@ -9,27 +14,27 @@ import { SidebarStateSelect, TPeekOverviewModes, } from "components/issues"; -// icons +// ui import { CustomDatePicker, Icon } from "components/ui"; +// helpers import { copyTextToClipboard } from "helpers/string.helper"; -import useToast from "hooks/use-toast"; // types import { IIssue } from "types"; type Props = { handleDeleteIssue: () => void; + handleUpdateIssue: (formData: Partial<IIssue>) => Promise<void>; issue: IIssue; mode: TPeekOverviewModes; - onChange: (issueProperty: Partial<IIssue>) => void; readOnly: boolean; workspaceSlug: string; }; export const PeekOverviewIssueProperties: React.FC<Props> = ({ handleDeleteIssue, + handleUpdateIssue, issue, mode, - onChange, readOnly, workspaceSlug, }) => { @@ -86,7 +91,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ <div className="w-3/4"> <SidebarStateSelect value={issue.state} - onChange={(val: string) => onChange({ state: val })} + onChange={(val: string) => handleUpdateIssue({ state: val })} disabled={readOnly} /> </div> @@ -99,7 +104,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ <div className="w-3/4"> <SidebarAssigneeSelect value={issue.assignees_list} - onChange={(val: string[]) => onChange({ assignees_list: val })} + onChange={(val: string[]) => handleUpdateIssue({ assignees_list: val })} disabled={readOnly} /> </div> @@ -112,7 +117,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ <div className="w-3/4"> <SidebarPrioritySelect value={issue.priority} - onChange={(val: string) => onChange({ priority: val })} + onChange={(val: string) => handleUpdateIssue({ priority: val })} disabled={readOnly} /> </div> @@ -128,7 +133,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ placeholder="Start date" value={issue.start_date} onChange={(val) => - onChange({ + handleUpdateIssue({ start_date: val, }) } @@ -153,7 +158,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ placeholder="Due date" value={issue.target_date} onChange={(val) => - onChange({ + handleUpdateIssue({ target_date: val, }) } @@ -175,7 +180,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ <div className="w-3/4"> <SidebarEstimateSelect value={issue.estimate_point} - onChange={(val: number | null) => onChange({ estimate_point: val })} + onChange={(val: number | null) =>handleUpdateIssue({ estimate_point: val })} disabled={readOnly} /> </div> diff --git a/apps/app/components/issues/peek-overview/layout.tsx b/apps/app/components/issues/peek-overview/layout.tsx index 7196052f8..50fa5df68 100644 --- a/apps/app/components/issues/peek-overview/layout.tsx +++ b/apps/app/components/issues/peek-overview/layout.tsx @@ -1,107 +1,184 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/router"; + +// mobx +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; // headless ui import { Dialog, Transition } from "@headlessui/react"; +// hooks +import useUser from "hooks/use-user"; +// components import { FullScreenPeekView, SidePeekView } from "components/issues"; // types import { IIssue } from "types"; type Props = { - handleDeleteIssue: () => void; - handleUpdateIssue: (issue: Partial<IIssue>) => Promise<void>; - issue: IIssue | null; - isOpen: boolean; - onClose: () => void; - workspaceSlug: string; + handleMutation: () => void; + projectId: string; readOnly: boolean; + workspaceSlug: string; }; export type TPeekOverviewModes = "side" | "modal" | "full"; -export const IssuePeekOverview: React.FC<Props> = ({ - handleDeleteIssue, - handleUpdateIssue, - issue, - isOpen, - onClose, - workspaceSlug, - readOnly, -}) => { - const [peekOverviewMode, setPeekOverviewMode] = useState<TPeekOverviewModes>("side"); +export const IssuePeekOverview: React.FC<Props> = observer( + ({ handleMutation, projectId, readOnly, workspaceSlug }) => { + const [isSidePeekOpen, setIsSidePeekOpen] = useState(false); + const [isModalPeekOpen, setIsModalPeekOpen] = useState(false); + const [peekOverviewMode, setPeekOverviewMode] = useState<TPeekOverviewModes>("side"); - const handleClose = () => { - onClose(); - setPeekOverviewMode("side"); - }; + const router = useRouter(); + const { peekIssue } = router.query; - if (!issue || !isOpen) return null; + const { issues: issuesStore } = useMobxStore(); + const { deleteIssue, getIssueById, issues, updateIssue } = issuesStore; - return ( - <Transition.Root show={isOpen} as={React.Fragment}> - <Dialog as="div" className="relative z-20" onClose={handleClose}> - {/* add backdrop conditionally */} - {(peekOverviewMode === "modal" || peekOverviewMode === "full") && ( - <Transition.Child - as={React.Fragment} - enter="ease-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in duration-200" - leaveFrom="opacity-100" - leaveTo="opacity-0" - > - <div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" /> - </Transition.Child> - )} - <div className="fixed inset-0 z-20 overflow-y-auto"> - <div className="relative h-full w-full"> + const issue = issues[peekIssue?.toString() ?? ""]; + + const { user } = useUser(); + + const handleClose = () => { + const { query } = router; + delete query.peekIssue; + + router.push({ + pathname: router.pathname, + query: { ...query }, + }); + }; + + const handleUpdateIssue = async (formData: Partial<IIssue>) => { + if (!issue || !user) return; + + await updateIssue(workspaceSlug, projectId, issue.id, formData, user); + handleMutation(); + }; + + const handleDeleteIssue = async () => { + if (!issue || !user) return; + + await deleteIssue(workspaceSlug, projectId, issue.id, user); + handleMutation(); + + handleClose(); + }; + + useEffect(() => { + if (!peekIssue) return; + + getIssueById(workspaceSlug, projectId, peekIssue.toString()); + }, [getIssueById, peekIssue, projectId, workspaceSlug]); + + useEffect(() => { + if (peekIssue) { + if (peekOverviewMode === "side") { + setIsSidePeekOpen(true); + setIsModalPeekOpen(false); + } else { + setIsModalPeekOpen(true); + setIsSidePeekOpen(false); + } + } else { + console.log("Triggered"); + setIsSidePeekOpen(false); + setIsModalPeekOpen(false); + } + }, [peekIssue, peekOverviewMode]); + + return ( + <> + <Transition.Root appear show={isSidePeekOpen} as={React.Fragment}> + <Dialog as="div" className="relative z-20" onClose={handleClose}> + <div className="fixed inset-0 z-20 overflow-y-auto"> + <div className="relative h-full w-full"> + <Transition.Child + as={React.Fragment} + enter="transition-transform duration-300" + enterFrom="translate-x-full" + enterTo="translate-x-0" + leave="transition-transform duration-200" + leaveFrom="translate-x-0" + leaveTo="translate-x-full" + > + <Dialog.Panel className="absolute z-20 bg-custom-background-100 top-0 right-0 h-full w-1/2 shadow-custom-shadow-md"> + <SidePeekView + handleClose={handleClose} + handleDeleteIssue={handleDeleteIssue} + handleUpdateIssue={handleUpdateIssue} + issue={issue} + mode={peekOverviewMode} + readOnly={readOnly} + setMode={(mode) => setPeekOverviewMode(mode)} + workspaceSlug={workspaceSlug} + /> + </Dialog.Panel> + </Transition.Child> + </div> + </div> + </Dialog> + </Transition.Root> + <Transition.Root appear show={isModalPeekOpen} as={React.Fragment}> + <Dialog as="div" className="relative z-20" onClose={handleClose}> <Transition.Child as={React.Fragment} enter="ease-out duration-300" - enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" - enterTo="opacity-100 translate-y-0 sm:scale-100" + enterFrom="opacity-0" + enterTo="opacity-100" leave="ease-in duration-200" - leaveFrom="opacity-100 translate-y-0 sm:scale-100" - leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" + leaveFrom="opacity-100" + leaveTo="opacity-0" > - <Dialog.Panel - className={`absolute z-20 bg-custom-background-100 ${ - peekOverviewMode === "side" - ? "top-0 right-0 h-full w-1/2 shadow-custom-shadow-md" - : peekOverviewMode === "modal" - ? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[70%] w-3/5 rounded-lg shadow-custom-shadow-xl" - : "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[95%] w-[95%] rounded-lg shadow-custom-shadow-xl" - }`} - > - {(peekOverviewMode === "side" || peekOverviewMode === "modal") && ( - <SidePeekView - handleClose={handleClose} - handleDeleteIssue={handleDeleteIssue} - handleUpdateIssue={handleUpdateIssue} - issue={issue} - mode={peekOverviewMode} - readOnly={readOnly} - setMode={(mode) => setPeekOverviewMode(mode)} - workspaceSlug={workspaceSlug} - /> - )} - {peekOverviewMode === "full" && ( - <FullScreenPeekView - handleClose={handleClose} - handleDeleteIssue={handleDeleteIssue} - handleUpdateIssue={handleUpdateIssue} - issue={issue} - mode={peekOverviewMode} - readOnly={readOnly} - setMode={(mode) => setPeekOverviewMode(mode)} - workspaceSlug={workspaceSlug} - /> - )} - </Dialog.Panel> + <div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" /> </Transition.Child> - </div> - </div> - </Dialog> - </Transition.Root> - ); -}; + <div className="fixed inset-0 z-20 overflow-y-auto"> + <div className="relative h-full w-full"> + <Transition.Child + as={React.Fragment} + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <Dialog.Panel + className={`absolute z-20 bg-custom-background-100 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg shadow-custom-shadow-xl transition-all duration-300 ${ + peekOverviewMode === "modal" ? "h-[70%] w-3/5" : "h-[95%] w-[95%]" + }`} + > + {peekOverviewMode === "modal" && ( + <SidePeekView + handleClose={handleClose} + handleDeleteIssue={handleDeleteIssue} + handleUpdateIssue={handleUpdateIssue} + issue={issue} + mode={peekOverviewMode} + readOnly={readOnly} + setMode={(mode) => setPeekOverviewMode(mode)} + workspaceSlug={workspaceSlug} + /> + )} + {peekOverviewMode === "full" && ( + <FullScreenPeekView + handleClose={handleClose} + handleDeleteIssue={handleDeleteIssue} + handleUpdateIssue={handleUpdateIssue} + issue={issue} + mode={peekOverviewMode} + readOnly={readOnly} + setMode={(mode) => setPeekOverviewMode(mode)} + workspaceSlug={workspaceSlug} + /> + )} + </Dialog.Panel> + </Transition.Child> + </div> + </div> + </Dialog> + </Transition.Root> + </> + ); + } +); diff --git a/apps/app/components/issues/peek-overview/side-peek-view.tsx b/apps/app/components/issues/peek-overview/side-peek-view.tsx index f938c3805..1bdeed479 100644 --- a/apps/app/components/issues/peek-overview/side-peek-view.tsx +++ b/apps/app/components/issues/peek-overview/side-peek-view.tsx @@ -1,3 +1,4 @@ +// components import { PeekOverviewHeader, PeekOverviewIssueActivity, @@ -5,13 +6,16 @@ import { PeekOverviewIssueProperties, TPeekOverviewModes, } from "components/issues"; +// ui +import { Loader } from "components/ui"; +// types import { IIssue } from "types"; type Props = { handleClose: () => void; handleDeleteIssue: () => void; - handleUpdateIssue: (issue: Partial<IIssue>) => Promise<void>; - issue: IIssue; + handleUpdateIssue: (formData: Partial<IIssue>) => Promise<void>; + issue: IIssue | undefined; mode: TPeekOverviewModes; readOnly: boolean; setMode: (mode: TPeekOverviewModes) => void; @@ -39,37 +43,50 @@ export const SidePeekView: React.FC<Props> = ({ workspaceSlug={workspaceSlug} /> </div> - <div className="h-full w-full px-6 overflow-y-auto"> - {/* issue title and description */} - <div className="w-full"> - <PeekOverviewIssueDetails - handleUpdateIssue={handleUpdateIssue} - issue={issue} - readOnly={readOnly} - workspaceSlug={workspaceSlug} - /> + {issue ? ( + <div className="h-full w-full px-6 overflow-y-auto"> + {/* issue title and description */} + <div className="w-full"> + <PeekOverviewIssueDetails + handleUpdateIssue={handleUpdateIssue} + issue={issue} + readOnly={readOnly} + workspaceSlug={workspaceSlug} + /> + </div> + {/* issue properties */} + <div className="w-full mt-10"> + <PeekOverviewIssueProperties + handleDeleteIssue={handleDeleteIssue} + handleUpdateIssue={handleUpdateIssue} + issue={issue} + mode={mode} + readOnly={readOnly} + workspaceSlug={workspaceSlug} + /> + </div> + {/* divider */} + <div className="h-[1] w-full border-t border-custom-border-200 my-5" /> + {/* issue activity/comments */} + <div className="w-full pb-5"> + {issue && ( + <PeekOverviewIssueActivity + workspaceSlug={workspaceSlug} + issue={issue} + readOnly={readOnly} + /> + )} + </div> </div> - {/* issue properties */} - <div className="w-full mt-10"> - <PeekOverviewIssueProperties - handleDeleteIssue={handleDeleteIssue} - issue={issue} - mode={mode} - onChange={handleUpdateIssue} - readOnly={readOnly} - workspaceSlug={workspaceSlug} - /> - </div> - {/* divider */} - <div className="h-[1] w-full border-t border-custom-border-200 my-5" /> - {/* issue activity/comments */} - <div className="w-full pb-5"> - <PeekOverviewIssueActivity - workspaceSlug={workspaceSlug} - issue={issue} - readOnly={readOnly} - /> - </div> - </div> + ) : ( + <Loader className="px-6"> + <Loader.Item height="30px" /> + <div className="space-y-2 mt-3"> + <Loader.Item height="20px" width="70%" /> + <Loader.Item height="20px" width="60%" /> + <Loader.Item height="20px" width="60%" /> + </div> + </Loader> + )} </div> ); diff --git a/apps/app/hooks/use-spreadsheet-issues-view.tsx b/apps/app/hooks/use-spreadsheet-issues-view.tsx index 4471b4352..145313dac 100644 --- a/apps/app/hooks/use-spreadsheet-issues-view.tsx +++ b/apps/app/hooks/use-spreadsheet-issues-view.tsx @@ -48,7 +48,7 @@ const useSpreadsheetIssuesView = () => { sub_issue: "false", }; - const { data: projectSpreadsheetIssues } = useSWR( + const { data: projectSpreadsheetIssues, mutate: mutateProjectSpreadsheetIssues } = useSWR( workspaceSlug && projectId ? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params) : null, @@ -58,7 +58,7 @@ const useSpreadsheetIssuesView = () => { : null ); - const { data: cycleSpreadsheetIssues } = useSWR( + const { data: cycleSpreadsheetIssues, mutate: mutateCycleSpreadsheetIssues } = useSWR( workspaceSlug && projectId && cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) : null, @@ -73,7 +73,7 @@ const useSpreadsheetIssuesView = () => { : null ); - const { data: moduleSpreadsheetIssues } = useSWR( + const { data: moduleSpreadsheetIssues, mutate: mutateModuleSpreadsheetIssues } = useSWR( workspaceSlug && projectId && moduleId ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) : null, @@ -88,7 +88,7 @@ const useSpreadsheetIssuesView = () => { : null ); - const { data: viewSpreadsheetIssues } = useSWR( + const { data: viewSpreadsheetIssues, mutate: mutateViewSpreadsheetIssues } = useSWR( workspaceSlug && projectId && viewId && params ? VIEW_ISSUES(viewId.toString(), params) : null, workspaceSlug && projectId && viewId && params ? () => @@ -106,6 +106,13 @@ const useSpreadsheetIssuesView = () => { return { issueView, + mutateIssues: cycleId + ? mutateCycleSpreadsheetIssues + : moduleId + ? mutateModuleSpreadsheetIssues + : viewId + ? mutateViewSpreadsheetIssues + : mutateProjectSpreadsheetIssues, spreadsheetIssues: spreadsheetIssues ?? [], orderBy, setOrderBy, diff --git a/apps/app/store/issues.ts b/apps/app/store/issues.ts new file mode 100644 index 000000000..538c5e2a9 --- /dev/null +++ b/apps/app/store/issues.ts @@ -0,0 +1,172 @@ +// mobx +import { action, observable, runInAction, makeAutoObservable } from "mobx"; +// services +import issueService from "services/issues.service"; +// types +import type { ICurrentUserResponse, IIssue } from "types"; + +class IssuesStore { + issues: { [key: string]: IIssue } = {}; + isIssuesLoading: boolean = false; + rootStore: any | null = null; + + constructor(_rootStore: any | null = null) { + makeAutoObservable(this, { + issues: observable.ref, + loadIssues: action, + getIssueById: action, + isIssuesLoading: observable, + createIssue: action, + updateIssue: action, + deleteIssue: action, + }); + + this.rootStore = _rootStore; + } + + /** + * @description Fetch all issues of a project and hydrate issues field + */ + + loadIssues = async (workspaceSlug: string, projectId: string) => { + this.isIssuesLoading = true; + try { + const issuesResponse: IIssue[] = (await issueService.getIssuesWithParams( + workspaceSlug, + projectId + )) as IIssue[]; + + const issues: { [kye: string]: IIssue } = {}; + issuesResponse.forEach((issue) => { + issues[issue.id] = issue; + }); + + runInAction(() => { + this.issues = issues; + this.isIssuesLoading = false; + }); + } catch (error) { + this.isIssuesLoading = false; + console.error("Fetching issues error", error); + } + }; + + getIssueById = async ( + workspaceSlug: string, + projectId: string, + issueId: string + ): Promise<IIssue> => { + if (this.issues[issueId]) return this.issues[issueId]; + + try { + const issueResponse: IIssue = await issueService.retrieve(workspaceSlug, projectId, issueId); + + const issues = { + ...this.issues, + [issueId]: { ...issueResponse }, + }; + + runInAction(() => { + this.issues = issues; + }); + + return issueResponse; + } catch (error) { + throw error; + } + }; + + createIssue = async ( + workspaceSlug: string, + projectId: string, + issueForm: IIssue, + user: ICurrentUserResponse + ): Promise<IIssue> => { + try { + const issueResponse = await issueService.createIssues( + workspaceSlug, + projectId, + issueForm, + user + ); + + const issues = { + ...this.issues, + [issueResponse.id]: { ...issueResponse }, + }; + + runInAction(() => { + this.issues = issues; + }); + return issueResponse; + } catch (error) { + console.error("Creating issue error", error); + throw error; + } + }; + + updateIssue = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + issueForm: Partial<IIssue>, + user: ICurrentUserResponse + ) => { + // keep a copy of the issue in the store + const originalIssue = { ...this.issues[issueId] }; + + // immediately update the issue in the store + const updatedIssue = { ...originalIssue, ...issueForm }; + + try { + runInAction(() => { + this.issues[issueId] = updatedIssue; + }); + + // make a patch request to update the issue + const issueResponse: IIssue = await issueService.patchIssue( + workspaceSlug, + projectId, + issueId, + issueForm, + user + ); + + const updatedIssues = { ...this.issues }; + updatedIssues[issueId] = { ...issueResponse }; + + runInAction(() => { + this.issues = updatedIssues; + }); + } catch (error) { + // if there is an error, revert the changes + runInAction(() => { + this.issues[issueId] = originalIssue; + }); + + return error; + } + }; + + deleteIssue = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + user: ICurrentUserResponse + ) => { + const issues = { ...this.issues }; + delete issues[issueId]; + + try { + runInAction(() => { + this.issues = issues; + }); + + issueService.deleteIssue(workspaceSlug, projectId, issueId, user); + } catch (error) { + console.error("Deleting issue error", error); + } + }; +} + +export default IssuesStore; diff --git a/apps/app/store/root.ts b/apps/app/store/root.ts index 5895637a8..40dd62fe6 100644 --- a/apps/app/store/root.ts +++ b/apps/app/store/root.ts @@ -3,6 +3,7 @@ import { enableStaticRendering } from "mobx-react-lite"; // store imports import UserStore from "./user"; import ThemeStore from "./theme"; +import IssuesStore from "./issues"; import ProjectPublishStore, { IProjectPublishStore } from "./project-publish"; enableStaticRendering(typeof window === "undefined"); @@ -11,10 +12,12 @@ export class RootStore { user; theme; projectPublish: IProjectPublishStore; + issues: IssuesStore; constructor() { this.user = new UserStore(this); this.theme = new ThemeStore(this); this.projectPublish = new ProjectPublishStore(this); + this.issues = new IssuesStore(this); } } From 6c6b81bea7d123454c1c6e58ff43d1d94cf8e051 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Wed, 30 Aug 2023 16:38:04 +0530 Subject: [PATCH 21/27] chore: tracking the history of issue reactions and votes. (#2020) * chore: tracking the issues reaction and vote history * fix: changed the keywords for vote and reaction * chore: added validation --- apiserver/plane/api/views/issue.py | 114 +++++++++++- apiserver/plane/api/views/workspace.py | 1 + .../plane/bgtasks/issue_activites_task.py | 167 +++++++++++++++++- 3 files changed, 280 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 802431d2e..ac69e9d8d 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -486,7 +486,7 @@ class IssueActivityEndpoint(BaseAPIView): issue_activities = ( IssueActivity.objects.filter(issue_id=issue_id) .filter( - ~Q(field="comment"), + ~Q(field__in=["comment", "vote", "reaction"]), project__project_projectmember__member=self.request.user, ) .select_related("actor", "workspace", "issue", "project") @@ -1405,6 +1405,14 @@ class IssueReactionViewSet(BaseViewSet): project_id=self.kwargs.get("project_id"), actor=self.request.user, ) + issue_activity.delay( + type="issue_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) def destroy(self, request, slug, project_id, issue_id, reaction_code): try: @@ -1415,6 +1423,19 @@ class IssueReactionViewSet(BaseViewSet): reaction=reaction_code, actor=request.user, ) + issue_activity.delay( + type="issue_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(issue_reaction.id), + } + ), + ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) except IssueReaction.DoesNotExist: @@ -1455,6 +1476,14 @@ class CommentReactionViewSet(BaseViewSet): comment_id=self.kwargs.get("comment_id"), project_id=self.kwargs.get("project_id"), ) + issue_activity.delay( + type="comment_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) def destroy(self, request, slug, project_id, comment_id, reaction_code): try: @@ -1465,6 +1494,20 @@ class CommentReactionViewSet(BaseViewSet): reaction=reaction_code, actor=request.user, ) + issue_activity.delay( + type="comment_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(comment_reaction.id), + "comment_id": str(comment_id) + } + ), + ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) except CommentReaction.DoesNotExist: @@ -1691,6 +1734,14 @@ class IssueReactionPublicViewSet(BaseViewSet): project_id=project_id, member=request.user, ) + issue_activity.delay( + type="issue_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except ProjectDeployBoard.DoesNotExist: @@ -1722,6 +1773,19 @@ class IssueReactionPublicViewSet(BaseViewSet): reaction=reaction_code, actor=request.user, ) + issue_activity.delay( + type="issue_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(issue_reaction.id), + } + ), + ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) except IssueReaction.DoesNotExist: @@ -1784,8 +1848,21 @@ class CommentReactionPublicViewSet(BaseViewSet): project_id=project_id, member=request.user, ) + issue_activity.delay( + type="comment_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IssueComment.DoesNotExist: + return Response( + {"error": "Comment does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) except ProjectDeployBoard.DoesNotExist: return Response( {"error": "Project board does not exist"}, @@ -1816,6 +1893,20 @@ class CommentReactionPublicViewSet(BaseViewSet): reaction=reaction_code, actor=request.user, ) + issue_activity.delay( + type="comment_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(comment_reaction.id), + "comment_id": str(comment_id) + } + ), + ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) except CommentReaction.DoesNotExist: @@ -1861,6 +1952,14 @@ class IssueVotePublicViewSet(BaseViewSet): ) issue_vote.vote = request.data.get("vote", 1) issue_vote.save() + issue_activity.delay( + type="issue_vote.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) serializer = IssueVoteSerializer(issue_vote) return Response(serializer.data, status=status.HTTP_201_CREATED) except Exception as e: @@ -1878,6 +1977,19 @@ class IssueVotePublicViewSet(BaseViewSet): issue_id=issue_id, actor_id=request.user.id, ) + issue_activity.delay( + type="issue_vote.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "vote": str(issue_vote.vote), + "identifier": str(issue_vote.id), + } + ), + ) issue_vote.delete() return Response(status=status.HTTP_204_NO_CONTENT) except Exception as e: diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index b10fe3d42..cbf62548f 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -1197,6 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): projects = request.query_params.getlist("project", []) queryset = IssueActivity.objects.filter( + ~Q(field__in=["comment", "vote", "reaction"]), workspace__slug=slug, project__project_projectmember__member=request.user, actor=user_id, diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 1cc6c85cc..0cadac553 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -24,6 +24,9 @@ from plane.db.models import ( IssueSubscriber, Notification, IssueAssignee, + IssueReaction, + CommentReaction, + IssueComment, ) from plane.api.serializers import IssueActivitySerializer @@ -629,7 +632,7 @@ def update_issue_activity( "parent": track_parent, "priority": track_priority, "state": track_state, - "description": track_description, + "description_html": track_description, "target_date": track_target_date, "start_date": track_start_date, "labels_list": track_labels, @@ -1022,6 +1025,150 @@ def delete_attachment_activity( ) ) +def create_issue_reaction_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + if requested_data and requested_data.get("reaction") is not None: + issue_reaction = IssueReaction.objects.filter(reaction=requested_data.get("reaction"), project=project, actor=actor).values_list('id', flat=True).first() + if issue_reaction is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="created", + old_value=None, + new_value=requested_data.get("reaction"), + field="reaction", + project=project, + workspace=project.workspace, + comment="added the reaction", + old_identifier=None, + new_identifier=issue_reaction, + ) + ) + + +def delete_issue_reaction_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + if current_instance and current_instance.get("reaction") is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="deleted", + old_value=current_instance.get("reaction"), + new_value=None, + field="reaction", + project=project, + workspace=project.workspace, + comment="removed the reaction", + old_identifier=current_instance.get("identifier"), + new_identifier=None, + ) + ) + + +def create_comment_reaction_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + if requested_data and requested_data.get("reaction") is not None: + comment_reaction_id, comment_id = CommentReaction.objects.filter(reaction=requested_data.get("reaction"), project=project, actor=actor).values_list('id', 'comment__id').first() + comment = IssueComment.objects.get(pk=comment_id,project=project) + if comment is not None and comment_reaction_id is not None and comment_id is not None: + issue_activities.append( + IssueActivity( + issue_id=comment.issue_id, + actor=actor, + verb="created", + old_value=None, + new_value=requested_data.get("reaction"), + field="reaction", + project=project, + workspace=project.workspace, + comment="added the reaction", + old_identifier=None, + new_identifier=comment_reaction_id, + ) + ) + + +def delete_comment_reaction_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + if current_instance and current_instance.get("reaction") is not None: + issue_id = IssueComment.objects.filter(pk=current_instance.get("comment_id"), project=project).values_list('issue_id', flat=True).first() + if issue_id is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="deleted", + old_value=current_instance.get("reaction"), + new_value=None, + field="reaction", + project=project, + workspace=project.workspace, + comment="removed the reaction", + old_identifier=current_instance.get("identifier"), + new_identifier=None, + ) + ) + + +def create_issue_vote_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + if requested_data and requested_data.get("vote") is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="created", + old_value=None, + new_value=requested_data.get("vote"), + field="vote", + project=project, + workspace=project.workspace, + comment="added the vote", + old_identifier=None, + new_identifier=None, + ) + ) + + +def delete_issue_vote_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + if current_instance and current_instance.get("vote") is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="deleted", + old_value=current_instance.get("vote"), + new_value=None, + field="vote", + project=project, + workspace=project.workspace, + comment="removed the vote", + old_identifier=current_instance.get("identifier"), + new_identifier=None, + ) + ) + # Receive message from room group @shared_task @@ -1045,6 +1192,12 @@ def issue_activity( "cycle.activity.deleted", "module.activity.created", "module.activity.deleted", + "issue_reaction.activity.created", + "issue_reaction.activity.deleted", + "comment_reaction.activity.created", + "comment_reaction.activity.deleted", + "issue_vote.activity.created", + "issue_vote.activity.deleted", ]: issue = Issue.objects.filter(pk=issue_id).first() @@ -1080,6 +1233,12 @@ def issue_activity( "link.activity.deleted": delete_link_activity, "attachment.activity.created": create_attachment_activity, "attachment.activity.deleted": delete_attachment_activity, + "issue_reaction.activity.created": create_issue_reaction_activity, + "issue_reaction.activity.deleted": delete_issue_reaction_activity, + "comment_reaction.activity.created": create_comment_reaction_activity, + "comment_reaction.activity.deleted": delete_comment_reaction_activity, + "issue_vote.activity.created": create_issue_vote_activity, + "issue_vote.activity.deleted": delete_issue_vote_activity, } func = ACTIVITY_MAPPER.get(type) @@ -1119,6 +1278,12 @@ def issue_activity( "cycle.activity.deleted", "module.activity.created", "module.activity.deleted", + "issue_reaction.activity.created", + "issue_reaction.activity.deleted", + "comment_reaction.activity.created", + "comment_reaction.activity.deleted", + "issue_vote.activity.created", + "issue_vote.activity.deleted", ]: # Create Notifications bulk_notifications = [] From 54527cc2bb85bec500b01069f319fc58a6cd9c7e Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 30 Aug 2023 17:27:49 +0530 Subject: [PATCH 22/27] dev: revamp publish project modal (#2022) * dev: revamp publish project modal * chore: sidebar dropdown text --- .../project/publish-project/modal.tsx | 674 ++++++++++-------- .../project/publish-project/popover.tsx | 21 +- .../project/single-sidebar-project.tsx | 6 +- apps/app/store/project-publish.tsx | 22 +- 4 files changed, 377 insertions(+), 346 deletions(-) diff --git a/apps/app/components/project/publish-project/modal.tsx b/apps/app/components/project/publish-project/modal.tsx index 5f9d9ae2c..b22a496f5 100644 --- a/apps/app/components/project/publish-project/modal.tsx +++ b/apps/app/components/project/publish-project/modal.tsx @@ -1,28 +1,38 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; // next imports import { useRouter } from "next/router"; // react-hook-form -import { useForm } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; // headless ui import { Dialog, Transition } from "@headlessui/react"; // ui components -import { ToggleSwitch, PrimaryButton, SecondaryButton } from "components/ui"; +import { ToggleSwitch, PrimaryButton, SecondaryButton, Icon, DangerButton } from "components/ui"; import { CustomPopover } from "./popover"; // mobx react lite import { observer } from "mobx-react-lite"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; -import { IProjectPublishSettingsViews } from "store/project-publish"; +import { IProjectPublishSettings, TProjectPublishViews } from "store/project-publish"; // hooks import useToast from "hooks/use-toast"; import useProjectDetails from "hooks/use-project-details"; +import useUser from "hooks/use-user"; type Props = { // user: ICurrentUserResponse | undefined; }; -const defaultValues: Partial<any> = { +type FormData = { + id: string | null; + comments: boolean; + reactions: boolean; + votes: boolean; + inbox: string | null; + views: TProjectPublishViews[]; +}; + +const defaultValues: FormData = { id: null, comments: false, reactions: false, @@ -31,70 +41,73 @@ const defaultValues: Partial<any> = { views: ["list", "kanban"], }; -const viewOptions = [ - { key: "list", value: "List" }, - { key: "kanban", value: "Kanban" }, - // { key: "calendar", value: "Calendar" }, - // { key: "gantt", value: "Gantt" }, - // { key: "spreadsheet", value: "Spreadsheet" }, +const viewOptions: { + key: TProjectPublishViews; + label: string; +}[] = [ + { key: "list", label: "List" }, + { key: "kanban", label: "Kanban" }, + // { key: "calendar", label: "Calendar" }, + // { key: "gantt", label: "Gantt" }, + // { key: "spreadsheet", label: "Spreadsheet" }, ]; export const PublishProjectModal: React.FC<Props> = observer(() => { - const store: RootStore = useMobxStore(); - const { projectPublish } = store; + const [isUnpublishing, setIsUnpublishing] = useState(false); + const [isUpdateRequired, setIsUpdateRequired] = useState(false); - const { projectDetails, mutateProjectDetails } = useProjectDetails(); - - const { setToastAlert } = useToast(); - const handleToastAlert = (title: string, type: string, message: string) => { - setToastAlert({ - title: title || "Title", - type: "error" || "warning", - message: message || "Message", - }); - }; - - const { NEXT_PUBLIC_DEPLOY_URL } = process.env; - const plane_deploy_url = NEXT_PUBLIC_DEPLOY_URL - ? NEXT_PUBLIC_DEPLOY_URL - : "http://localhost:3001"; + const plane_deploy_url = process.env.NEXT_PUBLIC_DEPLOY_URL ?? "http://localhost:4000"; const router = useRouter(); const { workspaceSlug } = router.query; + const store: RootStore = useMobxStore(); + const { projectPublish } = store; + + const { user } = useUser(); + + const { mutateProjectDetails } = useProjectDetails(); + + const { setToastAlert } = useToast(); + const { - formState: { errors, isSubmitting }, + control, + formState: { isSubmitting }, + getValues, handleSubmit, reset, watch, - setValue, - } = useForm<any>({ + } = useForm<FormData>({ defaultValues, - reValidateMode: "onChange", }); const handleClose = () => { projectPublish.handleProjectModal(null); + + setIsUpdateRequired(false); reset({ ...defaultValues }); }; + // prefill form with the saved settings if the project is already published useEffect(() => { if ( projectPublish.projectPublishSettings && - projectPublish.projectPublishSettings != "not-initialized" + projectPublish.projectPublishSettings !== "not-initialized" ) { - let userBoards: string[] = []; + let userBoards: TProjectPublishViews[] = []; + if (projectPublish.projectPublishSettings?.views) { - const _views: IProjectPublishSettingsViews | null = - projectPublish.projectPublishSettings?.views || null; - if (_views != null) { - if (_views.list) userBoards.push("list"); - if (_views.kanban) userBoards.push("kanban"); - if (_views.calendar) userBoards.push("calendar"); - if (_views.gantt) userBoards.push("gantt"); - if (_views.spreadsheet) userBoards.push("spreadsheet"); - userBoards = userBoards && userBoards.length > 0 ? userBoards : ["list"]; - } + const savedViews = projectPublish.projectPublishSettings?.views; + + if (!savedViews) return; + + if (savedViews.list) userBoards.push("list"); + if (savedViews.kanban) userBoards.push("kanban"); + if (savedViews.calendar) userBoards.push("calendar"); + if (savedViews.gantt) userBoards.push("gantt"); + if (savedViews.spreadsheet) userBoards.push("spreadsheet"); + + userBoards = userBoards && userBoards.length > 0 ? userBoards : ["list"]; } const updatedData = { @@ -105,126 +118,105 @@ export const PublishProjectModal: React.FC<Props> = observer(() => { inbox: projectPublish.projectPublishSettings?.inbox || null, views: userBoards, }; + reset({ ...updatedData }); } }, [reset, projectPublish.projectPublishSettings]); + // fetch publish settings useEffect(() => { + if (!workspaceSlug) return; + if ( projectPublish.projectPublishModal && - workspaceSlug && - projectPublish.project_id != null && + projectPublish.project_id !== null && projectPublish?.projectPublishSettings === "not-initialized" ) { projectPublish.getProjectSettingsAsync( - workspaceSlug as string, - projectPublish.project_id as string, + workspaceSlug.toString(), + projectPublish.project_id, null ); } }, [workspaceSlug, projectPublish, projectPublish.projectPublishModal]); - const onSettingsPublish = async (formData: any) => { - if (formData.views && formData.views.length > 0) { - const payload = { - comments: formData.comments || false, - reactions: formData.reactions || false, - votes: formData.votes || false, - inbox: formData.inbox || null, - views: { - list: formData.views.includes("list") || false, - kanban: formData.views.includes("kanban") || false, - calendar: formData.views.includes("calendar") || false, - gantt: formData.views.includes("gantt") || false, - spreadsheet: formData.views.includes("spreadsheet") || false, - }, - }; + const handlePublishProject = async (payload: IProjectPublishSettings) => { + if (!workspaceSlug || !user) return; - const _workspaceSlug = workspaceSlug; - const _projectId = projectPublish.project_id; - - return projectPublish - .createProjectSettingsAsync(_workspaceSlug as string, _projectId as string, payload, null) - .then((response) => { - mutateProjectDetails(); - handleClose(); - console.log("_projectId", _projectId); - if (_projectId) - window.open(`${plane_deploy_url}/${_workspaceSlug}/${_projectId}`, "_blank"); - return response; - }) - .catch((error) => { - console.error("error", error); - return error; - }); - } else { - handleToastAlert("Missing fields", "warning", "Please select at least one view to publish"); - } - }; - - const onSettingsUpdate = async (key: string, value: any) => { - const payload = { - comments: key === "comments" ? value : watch("comments"), - reactions: key === "reactions" ? value : watch("reactions"), - votes: key === "votes" ? value : watch("votes"), - inbox: key === "inbox" ? value : watch("inbox"), - views: - key === "views" - ? { - list: value.includes("list") ? true : false, - kanban: value.includes("kanban") ? true : false, - calendar: value.includes("calendar") ? true : false, - gantt: value.includes("gantt") ? true : false, - spreadsheet: value.includes("spreadsheet") ? true : false, - } - : { - list: watch("views").includes("list") ? true : false, - kanban: watch("views").includes("kanban") ? true : false, - calendar: watch("views").includes("calendar") ? true : false, - gantt: watch("views").includes("gantt") ? true : false, - spreadsheet: watch("views").includes("spreadsheet") ? true : false, - }, - }; + const projectId = projectPublish.project_id; return projectPublish - .updateProjectSettingsAsync( - workspaceSlug as string, - projectPublish.project_id as string, - watch("id"), + .createProjectSettingsAsync( + workspaceSlug.toString(), + projectId?.toString() ?? "", payload, - null + user ) .then((response) => { mutateProjectDetails(); + handleClose(); + if (projectId) window.open(`${plane_deploy_url}/${workspaceSlug}/${projectId}`, "_blank"); return response; }) + .catch((error) => { + console.error("error", error); + return error; + }); + }; + + const handleUpdatePublishSettings = async (payload: IProjectPublishSettings) => { + if (!workspaceSlug || !user) return; + + await projectPublish + .updateProjectSettingsAsync( + workspaceSlug.toString(), + projectPublish.project_id?.toString() ?? "", + payload.id ?? "", + payload, + user + ) + .then((res) => { + mutateProjectDetails(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Publish settings updated successfully!", + }); + + handleClose(); + return res; + }) .catch((error) => { console.log("error", error); return error; }); }; - const onSettingsUnPublish = async (formData: any) => + const handleUnpublishProject = async (publishId: string) => { + if (!workspaceSlug || !publishId) return; + + setIsUnpublishing(true); + projectPublish .deleteProjectSettingsAsync( - workspaceSlug as string, + workspaceSlug.toString(), projectPublish.project_id as string, - formData?.id, + publishId, null ) - .then((response) => { + .then((res) => { mutateProjectDetails(); - reset({ ...defaultValues }); + handleClose(); - return response; + return res; }) - .catch((error) => { - console.error("error", error); - return error; - }); + .catch((err) => err) + .finally(() => setIsUnpublishing(false)); + }; const CopyLinkToClipboard = ({ copy_link }: { copy_link: string }) => { - const [status, setStatus] = React.useState(false); + const [status, setStatus] = useState(false); const copyText = () => { navigator.clipboard.writeText(copy_link); @@ -244,6 +236,68 @@ export const PublishProjectModal: React.FC<Props> = observer(() => { ); }; + const handleFormSubmit = async (formData: FormData) => { + if (!formData.views || formData.views.length === 0) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Please select at least one view layout to publish the project.", + }); + return; + } + + const payload = { + comments: formData.comments, + reactions: formData.reactions, + votes: formData.votes, + inbox: formData.inbox, + views: { + list: formData.views.includes("list"), + kanban: formData.views.includes("kanban"), + calendar: formData.views.includes("calendar"), + gantt: formData.views.includes("gantt"), + spreadsheet: formData.views.includes("spreadsheet"), + }, + }; + + if (watch("id")) await handleUpdatePublishSettings({ id: watch("id") ?? "", ...payload }); + else await handlePublishProject(payload); + }; + + // check if an update is required or not + const checkIfUpdateIsRequired = () => { + if ( + !projectPublish.projectPublishSettings || + projectPublish.projectPublishSettings === "not-initialized" + ) + return; + + const currentSettings = projectPublish.projectPublishSettings as IProjectPublishSettings; + const newSettings = getValues(); + + if ( + currentSettings.comments !== newSettings.comments || + currentSettings.reactions !== newSettings.reactions || + currentSettings.votes !== newSettings.votes + ) { + setIsUpdateRequired(true); + return; + } + + let viewCheckFlag = 0; + viewOptions.forEach((option) => { + if (currentSettings.views[option.key] !== newSettings.views.includes(option.key)) + viewCheckFlag++; + }); + + if (viewCheckFlag !== 0) { + setIsUpdateRequired(true); + return; + } + + setIsUpdateRequired(false); + }; + return ( <Transition.Root show={projectPublish.projectPublishModal} as={React.Fragment}> <Dialog as="div" className="relative z-20" onClose={handleClose}> @@ -270,200 +324,190 @@ export const PublishProjectModal: React.FC<Props> = observer(() => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - <Dialog.Panel className="transform rounded-lg bg-custom-background-100 border border-custom-border-100 text-left shadow-xl transition-all w-full sm:w-3/5 lg:w-1/2 xl:w-2/5 space-y-4"> - {/* heading */} - <div className="p-3 px-4 pb-0 flex gap-2 justify-between items-center"> - <div className="font-medium text-xl">Publish</div> - {projectPublish.loader && ( - <div className="text-xs text-custom-text-400">Changes saved</div> - )} - <div - className="hover:bg-custom-background-90 w-[30px] h-[30px] rounded flex justify-center items-center cursor-pointer transition-all" - onClick={handleClose} - > - <span className="material-symbols-rounded text-[16px]">close</span> - </div> - </div> - - {/* content */} - <div className="space-y-3"> - {watch("id") && ( - <div className="flex items-center gap-1 px-4 text-custom-primary-100"> - <div className="w-[20px] h-[20px] overflow-hidden flex items-center"> - <span className="material-symbols-rounded text-[18px]"> - radio_button_checked - </span> - </div> - <div className="text-sm">This project is live on web</div> - </div> - )} - - <div className="mx-4 border border-custom-border-100 bg-custom-background-90 rounded p-3 py-2 relative flex gap-2 items-center"> - <div className="relative line-clamp-1 overflow-hidden w-full text-sm"> - {`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`} - </div> - <div className="flex-shrink-0 relative flex items-center gap-1"> - <a - href={`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`} - target="_blank" - rel="noreferrer" + <Dialog.Panel className="transform rounded-lg bg-custom-background-100 border border-custom-border-100 text-left shadow-xl transition-all w-full sm:w-3/5 lg:w-1/2 xl:w-2/5 "> + <form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4"> + {/* heading */} + <div className="px-6 pt-4 flex items-center justify-between gap-2"> + <h5 className="font-semibold text-xl inline-block">Publish</h5> + {watch("id") && ( + <DangerButton + onClick={() => handleUnpublishProject(watch("id") ?? "")} + className="!px-2 !py-1.5" + loading={isUnpublishing} > - <div className="border border-custom-border-100 bg-custom-background-100 w-[30px] h-[30px] rounded flex justify-center items-center hover:bg-custom-background-90 cursor-pointer"> - <span className="material-symbols-rounded text-[16px]">open_in_new</span> - </div> - </a> - <CopyLinkToClipboard - copy_link={`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`} - /> - </div> - </div> - - <div className="space-y-3 px-4"> - <div className="relative flex justify-between items-center gap-2"> - <div className="text-custom-text-100">Views</div> - <div> - <CustomPopover - label={ - watch("views") && watch("views").length > 0 - ? viewOptions - .filter( - (_view) => watch("views").includes(_view.key) && _view.value - ) - .map((_view) => _view.value) - .join(", ") - : `` - } - placeholder="Select views" - > - <> - {viewOptions && - viewOptions.length > 0 && - viewOptions.map((_view) => ( - <div - key={_view.value} - className={`relative flex items-center gap-2 justify-between p-1 m-1 px-2 cursor-pointer rounded-sm text-custom-text-200 ${ - watch("views").includes(_view.key) - ? `bg-custom-background-80 text-custom-text-100` - : `hover:bg-custom-background-80 hover:text-custom-text-100` - }`} - onClick={() => { - const _views = - watch("views") && watch("views").length > 0 - ? watch("views").includes(_view?.key) - ? watch("views").filter((_o: string) => _o !== _view?.key) - : [...watch("views"), _view?.key] - : [_view?.key]; - setValue("views", _views); - if (watch("id") != null) onSettingsUpdate("views", _views); - }} - > - <div className="text-sm">{_view.value}</div> - <div - className={`w-[18px] h-[18px] relative flex justify-center items-center`} - > - {watch("views") && - watch("views").length > 0 && - watch("views").includes(_view.key) && ( - <span className="material-symbols-rounded text-[18px]"> - done - </span> - )} - </div> - </div> - ))} - </> - </CustomPopover> - </div> - </div> - - {/* <div className="relative flex justify-between items-center gap-2"> - <div className="text-custom-text-100">Allow comments</div> - <div> - <ToggleSwitch - value={watch("comments") ?? false} - onChange={() => { - const _comments = !watch("comments"); - setValue("comments", _comments); - if (watch("id") != null) onSettingsUpdate("comments", _comments); - }} - size="sm" - /> - </div> - </div> */} - - {/* <div className="relative flex justify-between items-center gap-2"> - <div className="text-custom-text-100">Allow reactions</div> - <div> - <ToggleSwitch - value={watch("reactions") ?? false} - onChange={() => { - const _reactions = !watch("reactions"); - setValue("reactions", _reactions); - if (watch("id") != null) onSettingsUpdate("reactions", _reactions); - }} - size="sm" - /> - </div> - </div> */} - - {/* <div className="relative flex justify-between items-center gap-2"> - <div className="text-custom-text-100">Allow Voting</div> - <div> - <ToggleSwitch - value={watch("votes") ?? false} - onChange={() => { - const _votes = !watch("votes"); - setValue("votes", _votes); - if (watch("id") != null) onSettingsUpdate("votes", _votes); - }} - size="sm" - /> - </div> - </div> */} - - {/* <div className="relative flex justify-between items-center gap-2"> - <div className="text-custom-text-100">Allow issue proposals</div> - <div> - <ToggleSwitch - value={watch("inbox") ?? false} - onChange={() => { - setValue("inbox", !watch("inbox")); - }} - size="sm" - /> - </div> - </div> */} - </div> - </div> - - {/* modal handlers */} - <div className="border-t border-custom-border-300 p-3 px-4 relative flex justify-between items-center"> - <div className="flex items-center gap-1 text-custom-text-300"> - <div className="w-[20px] h-[20px] overflow-hidden flex items-center"> - <span className="material-symbols-rounded text-[18px]">public</span> - </div> - <div className="text-sm">Anyone with the link can access</div> - </div> - <div className="relative flex items-center gap-2"> - <SecondaryButton onClick={handleClose}>Cancel</SecondaryButton> - {watch("id") != null ? ( - <PrimaryButton - outline - onClick={handleSubmit(onSettingsUnPublish)} - disabled={isSubmitting} - > - {isSubmitting ? "Unpublishing..." : "Unpublish"} - </PrimaryButton> - ) : ( - <PrimaryButton - onClick={handleSubmit(onSettingsPublish)} - disabled={isSubmitting} - > - {isSubmitting ? "Publishing..." : "Publish"} - </PrimaryButton> + {isUnpublishing ? "Unpublishing..." : "Unpublish"} + </DangerButton> )} </div> - </div> + + {/* content */} + <div className="space-y-3 px-6"> + <div className="border border-custom-border-100 bg-custom-background-80 rounded-md px-3 py-2 relative flex gap-2 items-center"> + <div className="truncate flex-grow text-sm"> + {`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`} + </div> + <div className="flex-shrink-0 relative flex items-center gap-1"> + <CopyLinkToClipboard + copy_link={`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`} + /> + </div> + </div> + + {watch("id") && ( + <div className="flex items-center gap-1 text-custom-primary-100"> + <div className="w-5 h-5 overflow-hidden flex items-center"> + <Icon iconName="radio_button_checked" className="!text-lg" /> + </div> + <div className="text-sm">This project is live on web</div> + </div> + )} + + <div className="space-y-4"> + <div className="relative flex justify-between items-center gap-2"> + <div className="text-sm">Views</div> + <Controller + control={control} + name="views" + render={({ field: { onChange, value } }) => ( + <CustomPopover + label={ + value.length > 0 + ? viewOptions + .filter((v) => value.includes(v.key)) + .map((v) => v.label) + .join(", ") + : `` + } + placeholder="Select views" + > + <> + {viewOptions.map((option) => ( + <div + key={option.key} + className={`relative flex items-center gap-2 justify-between p-1 m-1 px-2 cursor-pointer rounded-sm text-custom-text-200 ${ + value.includes(option.key) + ? "bg-custom-background-80 text-custom-text-100" + : "hover:bg-custom-background-80 hover:text-custom-text-100" + }`} + onClick={() => { + const _views = + value.length > 0 + ? value.includes(option.key) + ? value.filter((_o: string) => _o !== option.key) + : [...value, option.key] + : [option.key]; + + if (_views.length === 0) return; + + onChange(_views); + checkIfUpdateIsRequired(); + }} + > + <div className="text-sm">{option.label}</div> + <div + className={`w-[18px] h-[18px] relative flex justify-center items-center`} + > + {value.length > 0 && value.includes(option.key) && ( + <Icon iconName="done" className="!text-lg" /> + )} + </div> + </div> + ))} + </> + </CustomPopover> + )} + /> + </div> + + <div className="relative flex justify-between items-center gap-2"> + <div className="text-sm">Allow comments</div> + <Controller + control={control} + name="comments" + render={({ field: { onChange, value } }) => ( + <ToggleSwitch + value={value} + onChange={(val) => { + onChange(val); + checkIfUpdateIsRequired(); + }} + size="sm" + /> + )} + /> + </div> + <div className="relative flex justify-between items-center gap-2"> + <div className="text-sm">Allow reactions</div> + <Controller + control={control} + name="reactions" + render={({ field: { onChange, value } }) => ( + <ToggleSwitch + value={value} + onChange={(val) => { + onChange(val); + checkIfUpdateIsRequired(); + }} + size="sm" + /> + )} + /> + </div> + <div className="relative flex justify-between items-center gap-2"> + <div className="text-sm">Allow voting</div> + <Controller + control={control} + name="votes" + render={({ field: { onChange, value } }) => ( + <ToggleSwitch + value={value} + onChange={(val) => { + onChange(val); + checkIfUpdateIsRequired(); + }} + size="sm" + /> + )} + /> + </div> + + {/* <div className="relative flex justify-between items-center gap-2"> + <div className="text-sm">Allow issue proposals</div> + <Controller + control={control} + name="inbox" + render={({ field: { onChange, value } }) => ( + <ToggleSwitch value={value} onChange={onChange} size="sm" /> + )} + /> + </div> */} + </div> + </div> + + {/* modal handlers */} + <div className="border-t border-custom-border-200 px-6 py-5 relative flex justify-between items-center"> + <div className="flex items-center gap-1 text-custom-text-400 text-sm"> + <Icon iconName="public" className="!text-base" /> + <div className="text-sm">Anyone with the link can access</div> + </div> + <div className="relative flex items-center gap-2"> + <SecondaryButton onClick={handleClose}>Cancel</SecondaryButton> + {watch("id") ? ( + <> + {isUpdateRequired && ( + <PrimaryButton type="submit" loading={isSubmitting}> + {isSubmitting ? "Updating..." : "Update settings"} + </PrimaryButton> + )} + </> + ) : ( + <PrimaryButton type="submit" loading={isSubmitting}> + {isSubmitting ? "Publishing..." : "Publish"} + </PrimaryButton> + )} + </div> + </div> + </form> </Dialog.Panel> </Transition.Child> </div> diff --git a/apps/app/components/project/publish-project/popover.tsx b/apps/app/components/project/publish-project/popover.tsx index 623675b9f..5ab2d6432 100644 --- a/apps/app/components/project/publish-project/popover.tsx +++ b/apps/app/components/project/publish-project/popover.tsx @@ -1,6 +1,9 @@ import React, { Fragment } from "react"; + // headless ui import { Popover, Transition } from "@headlessui/react"; +// icons +import { Icon } from "components/ui"; export const CustomPopover = ({ children, @@ -16,18 +19,14 @@ export const CustomPopover = ({ {({ open }) => ( <> <Popover.Button - className={`${ - open ? "" : "" - } relative flex items-center gap-1 border border-custom-border-300 shadow-sm p-1 px-2 ring-0 outline-none`} + className={`${open ? "" : ""} relative flex items-center gap-1 ring-0 outline-none`} > - <div className="text-sm font-medium"> - {label ? label : placeholder ? placeholder : "Select"} - </div> - <div className="w-[20px] h-[20px] relative flex justify-center items-center"> + <div className="text-sm">{label ?? placeholder}</div> + <div className="w-5 h-5 grid place-items-center"> {!open ? ( - <span className="material-symbols-rounded text-[20px]">expand_more</span> + <Icon iconName="expand_more" className="!text-base" /> ) : ( - <span className="material-symbols-rounded text-[20px]">expand_less</span> + <Icon iconName="expand_less" className="!text-base" /> )} </div> </Popover.Button> @@ -41,8 +40,8 @@ export const CustomPopover = ({ leaveFrom="opacity-100 translate-y-0" leaveTo="opacity-0 translate-y-1" > - <Popover.Panel className="absolute right-0 z-[9999]"> - <div className="overflow-hidden rounded-sm border border-custom-border-300 mt-1 overflow-y-auto bg-custom-background-90 shadow-lg focus:outline-none"> + <Popover.Panel className="absolute right-0 z-10 mt-1 min-w-[150px]"> + <div className="overflow-hidden rounded border border-custom-border-300 mt-1 overflow-y-auto bg-custom-background-90 shadow-custom-shadow-2xs focus:outline-none"> {children} </div> </Popover.Panel> diff --git a/apps/app/components/project/single-sidebar-project.tsx b/apps/app/components/project/single-sidebar-project.tsx index 7bfca0d2c..6fbdbbaf0 100644 --- a/apps/app/components/project/single-sidebar-project.tsx +++ b/apps/app/components/project/single-sidebar-project.tsx @@ -26,7 +26,6 @@ import { SettingsOutlined, } from "@mui/icons-material"; // helpers -import { truncateText } from "helpers/string.helper"; import { renderEmoji } from "helpers/emoji.helper"; // types import { IProject } from "types"; @@ -265,11 +264,10 @@ export const SingleSidebarProject: React.FC<Props> = observer( > <div className="flex-shrink-0 relative flex items-center justify-start gap-2"> <div className="rounded transition-all w-4 h-4 flex justify-center items-center text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 duration-300 cursor-pointer"> - <span className="material-symbols-rounded text-[16px]">ios_share</span> + <Icon iconName="ios_share" className="!text-base" /> </div> - <div>Publish</div> + <div>{project.is_deployed ? "Publish settings" : "Publish"}</div> </div> - {/* <PublishProjectModal /> */} </CustomMenu.MenuItem> )} diff --git a/apps/app/store/project-publish.tsx b/apps/app/store/project-publish.tsx index 1b27d5fff..ffc45f546 100644 --- a/apps/app/store/project-publish.tsx +++ b/apps/app/store/project-publish.tsx @@ -4,21 +4,11 @@ import { RootStore } from "./root"; // services import ProjectServices from "services/project-publish.service"; -export type IProjectPublishSettingsViewKeys = - | "list" - | "gantt" - | "kanban" - | "calendar" - | "spreadsheet" - | string; +export type TProjectPublishViews = "list" | "gantt" | "kanban" | "calendar" | "spreadsheet"; -export interface IProjectPublishSettingsViews { - list: boolean; - gantt: boolean; - kanban: boolean; - calendar: boolean; - spreadsheet: boolean; -} +export type TProjectPublishViewsSettings = { + [key in TProjectPublishViews]: boolean; +}; export interface IProjectPublishSettings { id?: string; @@ -26,8 +16,8 @@ export interface IProjectPublishSettings { comments: boolean; reactions: boolean; votes: boolean; - views: IProjectPublishSettingsViews; - inbox: null; + views: TProjectPublishViewsSettings; + inbox: string | null; } export interface IProjectPublishStore { From 5e00ffee0563215f8ee0503dfe242fb16fb3365c Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 30 Aug 2023 17:28:17 +0530 Subject: [PATCH 23/27] fix: bugs on the user profile page (#2018) --- apps/app/components/profile/sidebar.tsx | 19 ++----------- apps/app/contexts/profile-issues-context.tsx | 29 +++++++++++--------- apps/app/hooks/use-profile-issues.tsx | 17 +++++++++++- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/apps/app/components/profile/sidebar.tsx b/apps/app/components/profile/sidebar.tsx index ba574441a..a1236b397 100644 --- a/apps/app/components/profile/sidebar.tsx +++ b/apps/app/components/profile/sidebar.tsx @@ -3,8 +3,6 @@ import Link from "next/link"; import useSWR from "swr"; -// next-themes -import { useTheme } from "next-themes"; // headless ui import { Disclosure, Transition } from "@headlessui/react"; // services @@ -25,8 +23,6 @@ export const ProfileSidebar = () => { const router = useRouter(); const { workspaceSlug, userId } = router.query; - const { theme } = useTheme(); - const { user } = useUser(); const { data: userProjectsData } = useSWR( @@ -56,15 +52,7 @@ export const ProfileSidebar = () => { ]; return ( - <div - className="flex-shrink-0 md:h-full w-full md:w-80 overflow-y-auto" - style={{ - boxShadow: - theme === "light" - ? "0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12)" - : "0px 0px 4px 0px rgba(0, 0, 0, 0.20), 0px 2px 6px 0px rgba(0, 0, 0, 0.50)", - }} - > + <div className="flex-shrink-0 md:h-full w-full md:w-80 overflow-y-auto shadow-custom-shadow-sm"> {userProjectsData ? ( <> <div className="relative h-32"> @@ -127,12 +115,11 @@ export const ProfileSidebar = () => { project.assigned_issues + project.pending_issues + project.completed_issues; - const totalAssignedIssues = totalIssues - project.created_issues; const completedIssuePercentage = - totalAssignedIssues === 0 + project.assigned_issues === 0 ? 0 - : Math.round((project.completed_issues / totalAssignedIssues) * 100); + : Math.round((project.completed_issues / project.assigned_issues) * 100); return ( <Disclosure diff --git a/apps/app/contexts/profile-issues-context.tsx b/apps/app/contexts/profile-issues-context.tsx index 7fc6d6c02..db54f2470 100644 --- a/apps/app/contexts/profile-issues-context.tsx +++ b/apps/app/contexts/profile-issues-context.tsx @@ -197,23 +197,26 @@ export const ProfileIssuesContextProvider: React.FC<{ children: React.ReactNode }) => { const [state, dispatch] = useReducer(reducer, initialState); - const setIssueView = useCallback((property: TIssueViewOptions) => { - dispatch({ - type: "SET_ISSUE_VIEW", - payload: { - issueView: property, - }, - }); - - if (property === "kanban") { + const setIssueView = useCallback( + (property: TIssueViewOptions) => { dispatch({ - type: "SET_GROUP_BY_PROPERTY", + type: "SET_ISSUE_VIEW", payload: { - groupByProperty: "state_detail.group", + issueView: property, }, }); - } - }, []); + + if (property === "kanban" && state.groupByProperty === null) { + dispatch({ + type: "SET_GROUP_BY_PROPERTY", + payload: { + groupByProperty: "state_detail.group", + }, + }); + } + }, + [state] + ); const setGroupByProperty = useCallback((property: TIssueGroupByOptions) => { dispatch({ diff --git a/apps/app/hooks/use-profile-issues.tsx b/apps/app/hooks/use-profile-issues.tsx index 6b4d4abfa..c232199bc 100644 --- a/apps/app/hooks/use-profile-issues.tsx +++ b/apps/app/hooks/use-profile-issues.tsx @@ -71,8 +71,23 @@ const useProfileIssues = (workspaceSlug: string | undefined, userId: string | un allIssues: userProfileIssues, }; + if (groupByProperty === "state_detail.group") { + return userProfileIssues + ? Object.assign( + { + backlog: [], + unstarted: [], + started: [], + completed: [], + cancelled: [], + }, + userProfileIssues + ) + : undefined; + } + return userProfileIssues; - }, [userProfileIssues]); + }, [groupByProperty, userProfileIssues]); useEffect(() => { if (!userId || !filters) return; From 320608ea73ac3bd043250369af5601ac8921540b Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Thu, 31 Aug 2023 11:32:58 +0530 Subject: [PATCH 24/27] chore: return issue votes in public issue list endpoint (#2026) --- apiserver/plane/api/serializers/issue.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 2a75b2f48..1f4d814a4 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -681,6 +681,7 @@ class IssuePublicSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(read_only=True, source="project") state_detail = StateLiteSerializer(read_only=True, source="state") issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True) + votes = IssueVoteSerializer(read_only=True, many=True) class Meta: model = Issue @@ -697,6 +698,7 @@ class IssuePublicSerializer(BaseSerializer): "priority", "target_date", "issue_reactions", + "votes", ] read_only_fields = fields From 38b7f4382fda94d04f9c833adc09263df6b0ea4e Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:41:41 +0530 Subject: [PATCH 25/27] [feat]: Tiptap table integration (#2008) * added basic table support * fixed table position at bottom * fixed image node deletion logic's regression issue * added compatible styles * enabled slash commands * disabled slash command and bubble menu's node selector for table cells * added dropcursor support to type below the table/image * blocked image uploads for handledrop and paste actions --- .../components/tiptap/bubble-menu/index.tsx | 4 +- .../tiptap/bubble-menu/link-selector.tsx | 5 +- .../components/tiptap/extensions/index.tsx | 15 ++- .../tiptap/extensions/table/table-cell.ts | 31 ++++++ .../tiptap/extensions/table/table-header.ts | 7 ++ .../tiptap/extensions/table/table.ts | 9 ++ apps/app/components/tiptap/index.tsx | 2 + .../tiptap/plugins/delete-image.tsx | 2 +- .../tiptap/plugins/upload-image.tsx | 2 - apps/app/components/tiptap/props.tsx | 19 ++++ .../components/tiptap/slash-command/index.tsx | 13 +++ .../components/tiptap/table-menu/index.tsx | 96 +++++++++++++++++++ apps/app/package.json | 5 + apps/app/styles/editor.css | 79 +++++++++++++++ yarn.lock | 25 +++++ 15 files changed, 306 insertions(+), 8 deletions(-) create mode 100644 apps/app/components/tiptap/extensions/table/table-cell.ts create mode 100644 apps/app/components/tiptap/extensions/table/table-header.ts create mode 100644 apps/app/components/tiptap/extensions/table/table.ts create mode 100644 apps/app/components/tiptap/table-menu/index.tsx diff --git a/apps/app/components/tiptap/bubble-menu/index.tsx b/apps/app/components/tiptap/bubble-menu/index.tsx index e68900782..7e72963b6 100644 --- a/apps/app/components/tiptap/bubble-menu/index.tsx +++ b/apps/app/components/tiptap/bubble-menu/index.tsx @@ -77,14 +77,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => { {...bubbleMenuProps} className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl" > - <NodeSelector + {!props.editor.isActive("table") && <NodeSelector editor={props.editor!} isOpen={isNodeSelectorOpen} setIsOpen={() => { setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsLinkSelectorOpen(false); }} - /> + />} <LinkSelector editor={props.editor!!} isOpen={isLinkSelectorOpen} diff --git a/apps/app/components/tiptap/bubble-menu/link-selector.tsx b/apps/app/components/tiptap/bubble-menu/link-selector.tsx index 1596870f7..16072f4be 100644 --- a/apps/app/components/tiptap/bubble-menu/link-selector.tsx +++ b/apps/app/components/tiptap/bubble-menu/link-selector.tsx @@ -6,7 +6,7 @@ import isValidHttpUrl from "./utils/link-validator"; interface LinkSelectorProps { editor: Editor; isOpen: boolean; - setIsOpen: Dispatch<SetStateAction<boolean>>; + setIsOpen: Dispatch<SetStateAction<boolean>> } @@ -52,7 +52,8 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 dow-xl animate-in fade-in slide-in-from-top-1" onKeyDown={(e) => { if (e.key === "Enter") { - e.preventDefault(); onLinkSubmit(); + e.preventDefault(); + onLinkSubmit(); } }} > diff --git a/apps/app/components/tiptap/extensions/index.tsx b/apps/app/components/tiptap/extensions/index.tsx index 2c5ffd10a..fa257b20a 100644 --- a/apps/app/components/tiptap/extensions/index.tsx +++ b/apps/app/components/tiptap/extensions/index.tsx @@ -13,6 +13,7 @@ import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import { lowlight } from "lowlight/lib/core"; import SlashCommand from "../slash-command"; import { InputRule } from "@tiptap/core"; +import Gapcursor from '@tiptap/extension-gapcursor' import ts from "highlight.js/lib/languages/typescript"; @@ -20,6 +21,10 @@ import "highlight.js/styles/github-dark.css"; import UniqueID from "@tiptap-pro/extension-unique-id"; import UpdatedImage from "./updated-image"; import isValidHttpUrl from "../bubble-menu/utils/link-validator"; +import { CustomTableCell } from "./table/table-cell"; +import { Table } from "./table/table"; +import { TableHeader } from "./table/table-header"; +import { TableRow } from "@tiptap/extension-table-row"; lowlight.registerLanguage("ts", ts); @@ -55,7 +60,7 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub codeBlock: false, horizontalRule: false, dropcursor: { - color: "#DBEAFE", + color: "rgba(var(--color-text-100))", width: 2, }, gapcursor: false, @@ -86,6 +91,7 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub class: "mb-6 border-t border-custom-border-300", }, }), + Gapcursor, TiptapLink.configure({ protocols: ["http", "https"], validate: (url) => isValidHttpUrl(url), @@ -104,6 +110,9 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub if (node.type.name === "heading") { return `Heading ${node.attrs.level}`; } + if (node.type.name === "image" || node.type.name === "table") { + return "" + } return "Press '/' for commands..."; }, @@ -134,4 +143,8 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub html: true, transformCopiedText: true, }), + Table, + TableHeader, + CustomTableCell, + TableRow ]; diff --git a/apps/app/components/tiptap/extensions/table/table-cell.ts b/apps/app/components/tiptap/extensions/table/table-cell.ts new file mode 100644 index 000000000..94c5aced2 --- /dev/null +++ b/apps/app/components/tiptap/extensions/table/table-cell.ts @@ -0,0 +1,31 @@ +import { TableCell } from "@tiptap/extension-table-cell"; + +export const CustomTableCell = TableCell.extend({ + addAttributes() { + return { + ...this.parent?.(), + isHeader: { + default: false, + parseHTML: (element) => { isHeader: element.tagName === "TD" }, + renderHTML: (attributes) => { tag: attributes.isHeader ? "th" : "td" } + }, + }; + }, + renderHTML({ HTMLAttributes }) { + if (HTMLAttributes.isHeader) { + return [ + "th", + { + ...HTMLAttributes, + class: `relative ${HTMLAttributes.class}`, + }, + [ + "span", + { class: "absolute top-0 right-0" }, + ], + 0, + ]; + } + return ["td", HTMLAttributes, 0]; + }, +}); diff --git a/apps/app/components/tiptap/extensions/table/table-header.ts b/apps/app/components/tiptap/extensions/table/table-header.ts new file mode 100644 index 000000000..d04fe85d3 --- /dev/null +++ b/apps/app/components/tiptap/extensions/table/table-header.ts @@ -0,0 +1,7 @@ +import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header"; + +const TableHeader = BaseTableHeader.extend({ + content: "paragraph" +}); + +export { TableHeader }; diff --git a/apps/app/components/tiptap/extensions/table/table.ts b/apps/app/components/tiptap/extensions/table/table.ts new file mode 100644 index 000000000..b05dedb3b --- /dev/null +++ b/apps/app/components/tiptap/extensions/table/table.ts @@ -0,0 +1,9 @@ +import { Table as BaseTable } from "@tiptap/extension-table"; + +const Table = BaseTable.configure({ + resizable: true, + cellMinWidth: 100, + allowTableNodeSelection: true +}); + +export { Table }; diff --git a/apps/app/components/tiptap/index.tsx b/apps/app/components/tiptap/index.tsx index f0315cad4..869e44aad 100644 --- a/apps/app/components/tiptap/index.tsx +++ b/apps/app/components/tiptap/index.tsx @@ -5,6 +5,7 @@ import { TiptapExtensions } from "./extensions"; import { TiptapEditorProps } from "./props"; import { useImperativeHandle, useRef, forwardRef } from "react"; import { ImageResizer } from "./extensions/image-resize"; +import { TableMenu } from "./table-menu"; export interface ITipTapRichTextEditor { value: string; @@ -92,6 +93,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => { {editor && <EditorBubbleMenu editor={editor} />} <div className={`${editorContentCustomClassNames}`}> <EditorContent editor={editor} /> + {editor?.isActive("table") && <TableMenu editor={editor} />} {editor?.isActive("image") && <ImageResizer editor={editor} />} </div> </div> diff --git a/apps/app/components/tiptap/plugins/delete-image.tsx b/apps/app/components/tiptap/plugins/delete-image.tsx index 57ab65c63..262a3f591 100644 --- a/apps/app/components/tiptap/plugins/delete-image.tsx +++ b/apps/app/components/tiptap/plugins/delete-image.tsx @@ -16,6 +16,7 @@ const TrackImageDeletionPlugin = () => oldState.doc.descendants((oldNode, oldPos) => { if (oldNode.type.name !== 'image') return; + if (oldPos < 0 || oldPos > newState.doc.content.size) return; if (!newState.doc.resolve(oldPos).parent) return; const newNode = newState.doc.nodeAt(oldPos); @@ -28,7 +29,6 @@ const TrackImageDeletionPlugin = () => nodeExists = true; } }); - if (!nodeExists) { removedImages.push(oldNode as ProseMirrorNode); } diff --git a/apps/app/components/tiptap/plugins/upload-image.tsx b/apps/app/components/tiptap/plugins/upload-image.tsx index 0657bc82b..a13f8e18a 100644 --- a/apps/app/components/tiptap/plugins/upload-image.tsx +++ b/apps/app/components/tiptap/plugins/upload-image.tsx @@ -60,8 +60,6 @@ function findPlaceholder(state: EditorState, id: {}) { export async function startImageUpload(file: File, view: EditorView, pos: number, workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) { if (!file.type.includes("image/")) { return; - } else if (file.size / 1024 / 1024 > 20) { - return; } const id = {}; diff --git a/apps/app/components/tiptap/props.tsx b/apps/app/components/tiptap/props.tsx index d50fc29b0..69cddca1f 100644 --- a/apps/app/components/tiptap/props.tsx +++ b/apps/app/components/tiptap/props.tsx @@ -1,5 +1,6 @@ import { EditorProps } from "@tiptap/pm/view"; import { startImageUpload } from "./plugins/upload-image"; +import { findTableAncestor } from "./table-menu"; export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorProps { return { @@ -18,6 +19,15 @@ export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSu }, }, handlePaste: (view, event) => { + if (typeof window !== "undefined") { + const selection: any = window?.getSelection(); + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + if (findTableAncestor(range.startContainer)) { + return; + } + } + } if ( event.clipboardData && event.clipboardData.files && @@ -32,6 +42,15 @@ export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSu return false; }, handleDrop: (view, event, _slice, moved) => { + if (typeof window !== "undefined") { + const selection: any = window?.getSelection(); + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + if (findTableAncestor(range.startContainer)) { + return; + } + } + } if ( !moved && event.dataTransfer && diff --git a/apps/app/components/tiptap/slash-command/index.tsx b/apps/app/components/tiptap/slash-command/index.tsx index 38f5c9c0a..c843f0762 100644 --- a/apps/app/components/tiptap/slash-command/index.tsx +++ b/apps/app/components/tiptap/slash-command/index.tsx @@ -15,6 +15,7 @@ import { MinusSquare, CheckSquare, ImageIcon, + Table, } from "lucide-react"; import { startImageUpload } from "../plugins/upload-image"; import { cn } from "../utils"; @@ -46,6 +47,9 @@ const Command = Extension.create({ return [ Suggestion({ editor: this.editor, + allow({ editor }) { + return !editor.isActive("table"); + }, ...this.options.suggestion, }), ]; @@ -117,6 +121,15 @@ const getSuggestionItems = (workspaceSlug: string, setIsSubmitting?: (isSubmitti editor.chain().focus().deleteRange(range).setHorizontalRule().run(); }, }, + { + title: "Table", + description: "Create a Table", + searchTerms: ["table", "cell", "db", "data", "tabular"], + icon: <Table size={18} />, + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + }, + }, { title: "Numbered List", description: "Create a list with numbering.", diff --git a/apps/app/components/tiptap/table-menu/index.tsx b/apps/app/components/tiptap/table-menu/index.tsx new file mode 100644 index 000000000..878679a29 --- /dev/null +++ b/apps/app/components/tiptap/table-menu/index.tsx @@ -0,0 +1,96 @@ +import { useState, useEffect } from "react"; +import { Rows, Columns, ToggleRight } from "lucide-react"; +import { cn } from "../utils"; + +interface TableMenuItem { + name: string; + command: () => void; + icon: any; +} + +export const findTableAncestor = (node: Node | null): HTMLTableElement | null => { + while (node !== null && node.nodeName !== "TABLE") { + node = node.parentNode; + } + return node as HTMLTableElement; +}; + +export const TableMenu = ({ editor }: { editor: any }) => { + const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 }); + const items: TableMenuItem[] = [ + { + name: "Insert Column right", + command: () => editor.chain().focus().addColumnBefore().run(), + icon: Columns, + }, + { + name: "Insert Row below", + command: () => editor.chain().focus().addRowAfter().run(), + icon: Rows, + }, + { + name: "Delete Column", + command: () => editor.chain().focus().deleteColumn().run(), + icon: Columns, + }, + { + name: "Delete Rows", + command: () => editor.chain().focus().deleteRow().run(), + icon: Rows, + }, + { + name: "Toggle Header Row", + command: () => editor.chain().focus().toggleHeaderRow().run(), + icon: ToggleRight, + } + + ]; + + useEffect(() => { + if (typeof window !== "undefined") { + const handleWindowClick = () => { + const selection: any = window?.getSelection(); + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + const tableNode = findTableAncestor(range.startContainer); + if (tableNode) { + const tableRect = tableNode.getBoundingClientRect(); + const tableCenter = tableRect.left + tableRect.width / 2; + const menuWidth = 45; + const menuLeft = tableCenter - menuWidth / 2; + const tableBottom = tableRect.bottom; + setTableLocation({ bottom: tableBottom, left: menuLeft }); + } + } + } + + window.addEventListener("click", handleWindowClick); + + return () => { + window.removeEventListener("click", handleWindowClick); + }; + } + }, [tableLocation]); + + return ( + <section + className="fixed left-1/2 transform -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-xl" + style={{ bottom: `calc(100vh - ${tableLocation.bottom + 45}px)`, left: `${tableLocation.left}px` }} + > + {items.map((item, index) => ( + <button + key={index} + onClick={item.command} + className="p-2 text-custom-text-200 hover:bg-text-custom-text-100 hover:bg-custom-primary-100/10 active:bg-custom-background-100" + title={item.name} + > + <item.icon + className={cn("h-5 w-5 text-lg", { + "text-red-600": item.name.includes("Delete"), + })} + /> + </button> + ))} + </section> + ); +}; diff --git a/apps/app/package.json b/apps/app/package.json index 578a95716..b9f3bf25e 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -30,11 +30,16 @@ "@tiptap-pro/extension-unique-id": "^2.1.0", "@tiptap/extension-code-block-lowlight": "^2.0.4", "@tiptap/extension-color": "^2.0.4", + "@tiptap/extension-gapcursor": "^2.1.7", "@tiptap/extension-highlight": "^2.0.4", "@tiptap/extension-horizontal-rule": "^2.0.4", "@tiptap/extension-image": "^2.0.4", "@tiptap/extension-link": "^2.0.4", "@tiptap/extension-placeholder": "^2.0.4", + "@tiptap/extension-table": "^2.1.6", + "@tiptap/extension-table-cell": "^2.1.6", + "@tiptap/extension-table-header": "^2.1.6", + "@tiptap/extension-table-row": "^2.1.6", "@tiptap/extension-task-item": "^2.0.4", "@tiptap/extension-task-list": "^2.0.4", "@tiptap/extension-text-style": "^2.0.4", diff --git a/apps/app/styles/editor.css b/apps/app/styles/editor.css index 57c23c911..3332185d2 100644 --- a/apps/app/styles/editor.css +++ b/apps/app/styles/editor.css @@ -30,6 +30,10 @@ } } +.ProseMirror-gapcursor:after { + border-top: 1px solid rgb(var(--color-text-100)) !important; +} + /* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */ ul[data-type="taskList"] li > label { @@ -150,3 +154,78 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { transform: rotate(360deg); } } + +#tiptap-container { + table { + border-collapse: collapse; + table-layout: fixed; + margin: 0; + margin-bottom: 1.5rem; + margin-top: 1.5rem; + border: 2px solid rgb(var(--color-border-100)); + width: 100%; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + + td, + th { + min-width: 1em; + border: 2px solid rgb(var(--color-border-400)); + padding: 10px 15px; + vertical-align: top; + box-sizing: border-box; + position: relative; + transition: background-color 0.3s ease; + + > * { + margin-bottom: 0; + } + } + + th { + font-weight: bold; + text-align: left; + background-color: rgb(var(--color-primary-300)); + } + + td:hover { + background-color: rgba(var(--color-primary-300), 0.1); + } + + .selectedCell:after { + z-index: 2; + position: absolute; + content: ""; + left: 0; right: 0; top: 0; bottom: 0; + background-color: rgba(var(--color-primary-300), 0.1); + pointer-events: none; + } + + .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: -2px; + width: 2px; + background-color: rgb(var(--color-primary-400)); + pointer-events: none; + } + } +} + +.tableWrapper { + overflow-x: auto; +} + +.resize-cursor { + cursor: ew-resize; + cursor: col-resize; +} + +.ProseMirror table * p { + padding: 0px 1px; + margin: 6px 2px; +} + +.ProseMirror table * .is-empty::before { + opacity: 0; +} diff --git a/yarn.lock b/yarn.lock index ac134d60f..3eafeb5b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2282,6 +2282,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.0.4.tgz#c100a792fd41535ad6382aa8133d0d9c0b2cb2b8" integrity sha512-VxmKfBQjSSu1mNvHlydA4dJW/zawGKyqmnryiFNcUV9s+/HWLR5i9SiUl4wJM/B8sG8cQxClne5/LrCAeGNYuA== +"@tiptap/extension-gapcursor@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.1.7.tgz#5c0303ba37b4c066f3a3c5835fd0b298f0d3e919" + integrity sha512-7eoInzzk1sssoD3RMkwFC86U15Ja4ANve+8wIC+xhN4R3Oe3PY3lFbp1GQxCmaJj8b3rtjNKIQZ2zO0PH58afA== + "@tiptap/extension-hard-break@^2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.0.4.tgz#a4f70fa9a473270f7ec89f20a14b9122af5657bc" @@ -2349,6 +2354,26 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.0.4.tgz#13286dcf8780c55610ed65b24238b8395a5be824" integrity sha512-Men7LK6N/Dh3/G4/z2Z9WkDHM2Gxx1XyxYix2ZMf5CnqY37SeDNUnGDqit65pdIN3Y/TQnOZTkKSBilSAtXfJA== +"@tiptap/extension-table-cell@^2.1.6": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table-cell/-/extension-table-cell-2.1.7.tgz#87841144b8368c9611ad46f2134b637e2c33c8bc" + integrity sha512-p3e4FNdbKVIjOLHDcXrRtlP6FYPoN6hBUFjq6QZbf5g4+ao2Uq4bQCL+eKbYMxUVERl8g/Qu9X+jG99fVsBDjA== + +"@tiptap/extension-table-header@^2.1.6": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table-header/-/extension-table-header-2.1.7.tgz#4757834655e2c4edffa65bc6f6807eb59401e0d8" + integrity sha512-rolSUQxFJf/CEj2XBJpeMsLiLHASKrVIzZ2A/AZ9pT6WpFqmECi8r9xyutpJpx21n2Hrk46Y+uGFOKhyvbZ5ug== + +"@tiptap/extension-table-row@^2.1.6": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table-row/-/extension-table-row-2.1.7.tgz#f736a61035b271423ef18f65a25f8d1e240263a1" + integrity sha512-DBCaEMEuCCoOmr4fdDfp2jnmyWPt672rmCZ5WUuenJ47Cy4Ox2dV+qk5vBZ/yDQcq12WvzLMhdSnAo9pMMMa6Q== + +"@tiptap/extension-table@^2.1.6": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-2.1.7.tgz#c8a83744f60c76ae1e41438b04d5ac9e984afa66" + integrity sha512-nlKs35vTQOFW9lfw76S7kJvqVJAfHUlz1muQgWT0gNUlKJYINMXjUIg4Wcx8LTaITCCkp0lMGrLETGRNI+RyxA== + "@tiptap/extension-task-item@^2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@tiptap/extension-task-item/-/extension-task-item-2.0.4.tgz#71f46d35ac629ca10c5c23d4ad170007338a436e" From af929ab7414463a624a04ad3e4d3cc95239e7c21 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Thu, 31 Aug 2023 16:30:28 +0530 Subject: [PATCH 26/27] style: tiptap table (#2033) --- apps/app/components/tiptap/index.tsx | 6 +- .../components/tiptap/table-menu/index.tsx | 113 +++++++++++------- apps/app/styles/editor.css | 22 ++-- apps/app/styles/globals.css | 4 + 4 files changed, 90 insertions(+), 55 deletions(-) diff --git a/apps/app/components/tiptap/index.tsx b/apps/app/components/tiptap/index.tsx index 869e44aad..2ab6bf288 100644 --- a/apps/app/components/tiptap/index.tsx +++ b/apps/app/components/tiptap/index.tsx @@ -76,8 +76,8 @@ const Tiptap = (props: ITipTapRichTextEditor) => { const editorClassNames = `relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md ${noBorder ? "" : "border border-custom-border-200"} ${ - borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0" - } ${customClassName}`; + borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0" + } ${customClassName}`; if (!editor) return null; editorRef.current = editor; @@ -93,7 +93,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => { {editor && <EditorBubbleMenu editor={editor} />} <div className={`${editorContentCustomClassNames}`}> <EditorContent editor={editor} /> - {editor?.isActive("table") && <TableMenu editor={editor} />} + <TableMenu editor={editor} /> {editor?.isActive("image") && <ImageResizer editor={editor} />} </div> </div> diff --git a/apps/app/components/tiptap/table-menu/index.tsx b/apps/app/components/tiptap/table-menu/index.tsx index 878679a29..0da68410e 100644 --- a/apps/app/components/tiptap/table-menu/index.tsx +++ b/apps/app/components/tiptap/table-menu/index.tsx @@ -1,11 +1,13 @@ import { useState, useEffect } from "react"; import { Rows, Columns, ToggleRight } from "lucide-react"; import { cn } from "../utils"; +import { Tooltip } from "components/ui"; interface TableMenuItem { - name: string; command: () => void; icon: any; + key: string; + name: string; } export const findTableAncestor = (node: Node | null): HTMLTableElement | null => { @@ -17,79 +19,108 @@ export const findTableAncestor = (node: Node | null): HTMLTableElement | null => export const TableMenu = ({ editor }: { editor: any }) => { const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 }); + const isOpen = editor?.isActive("table"); + const items: TableMenuItem[] = [ { - name: "Insert Column right", command: () => editor.chain().focus().addColumnBefore().run(), icon: Columns, + key: "insert-column-right", + name: "Insert 1 column right", }, { - name: "Insert Row below", command: () => editor.chain().focus().addRowAfter().run(), icon: Rows, + key: "insert-row-below", + name: "Insert 1 row below", }, { - name: "Delete Column", command: () => editor.chain().focus().deleteColumn().run(), icon: Columns, + key: "delete-column", + name: "Delete column", }, { - name: "Delete Rows", command: () => editor.chain().focus().deleteRow().run(), icon: Rows, + key: "delete-row", + name: "Delete row", }, { - name: "Toggle Header Row", command: () => editor.chain().focus().toggleHeaderRow().run(), icon: ToggleRight, - } - + key: "toggle-header-row", + name: "Toggle header row", + }, ]; useEffect(() => { - if (typeof window !== "undefined") { - const handleWindowClick = () => { - const selection: any = window?.getSelection(); - if (selection.rangeCount !== 0) { - const range = selection.getRangeAt(0); - const tableNode = findTableAncestor(range.startContainer); - if (tableNode) { - const tableRect = tableNode.getBoundingClientRect(); - const tableCenter = tableRect.left + tableRect.width / 2; - const menuWidth = 45; - const menuLeft = tableCenter - menuWidth / 2; - const tableBottom = tableRect.bottom; - setTableLocation({ bottom: tableBottom, left: menuLeft }); + if (!window) return; + + const handleWindowClick = () => { + const selection: any = window?.getSelection(); + + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + const tableNode = findTableAncestor(range.startContainer); + + let parent = tableNode?.parentElement; + + if (tableNode) { + const tableRect = tableNode.getBoundingClientRect(); + const tableCenter = tableRect.left + tableRect.width / 2; + const menuWidth = 45; + const menuLeft = tableCenter - menuWidth / 2; + const tableBottom = tableRect.bottom; + + setTableLocation({ bottom: tableBottom, left: menuLeft }); + + while (parent) { + if (!parent.classList.contains("disable-scroll")) + parent.classList.add("disable-scroll"); + parent = parent.parentElement; } + } else { + const scrollDisabledContainers = document.querySelectorAll(".disable-scroll"); + + scrollDisabledContainers.forEach((container) => { + container.classList.remove("disable-scroll"); + }); } } + }; - window.addEventListener("click", handleWindowClick); + window.addEventListener("click", handleWindowClick); - return () => { - window.removeEventListener("click", handleWindowClick); - }; - } - }, [tableLocation]); + return () => { + window.removeEventListener("click", handleWindowClick); + }; + }, [tableLocation, editor]); return ( <section - className="fixed left-1/2 transform -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-xl" - style={{ bottom: `calc(100vh - ${tableLocation.bottom + 45}px)`, left: `${tableLocation.left}px` }} + className={`fixed left-1/2 transform -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${ + isOpen ? "block" : "hidden" + }`} + style={{ + bottom: `calc(100vh - ${tableLocation.bottom + 45}px)`, + left: `${tableLocation.left}px`, + }} > {items.map((item, index) => ( - <button - key={index} - onClick={item.command} - className="p-2 text-custom-text-200 hover:bg-text-custom-text-100 hover:bg-custom-primary-100/10 active:bg-custom-background-100" - title={item.name} - > - <item.icon - className={cn("h-5 w-5 text-lg", { - "text-red-600": item.name.includes("Delete"), - })} - /> - </button> + <Tooltip key={index} tooltipContent={item.name}> + <button + onClick={item.command} + className="p-1.5 text-custom-text-200 hover:bg-text-custom-text-100 hover:bg-custom-background-80 active:bg-custom-background-80 rounded" + title={item.name} + > + <item.icon + className={cn("h-4 w-4 text-lg", { + "text-red-600": item.key.includes("delete"), + })} + /> + </button> + </Tooltip> ))} </section> ); diff --git a/apps/app/styles/editor.css b/apps/app/styles/editor.css index 3332185d2..9da250dd1 100644 --- a/apps/app/styles/editor.css +++ b/apps/app/styles/editor.css @@ -144,7 +144,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { height: 20px; border-radius: 50%; border: 3px solid rgba(var(--color-text-200)); - border-top-color: rgba(var(--color-text-800)); + border-top-color: rgba(var(--color-text-800)); animation: spinning 0.6s linear infinite; } } @@ -160,16 +160,13 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { border-collapse: collapse; table-layout: fixed; margin: 0; - margin-bottom: 1.5rem; - margin-top: 1.5rem; - border: 2px solid rgb(var(--color-border-100)); + border: 1px solid rgb(var(--color-border-200)); width: 100%; - box-shadow: 0 0 10px rgba(0,0,0,0.1); td, th { min-width: 1em; - border: 2px solid rgb(var(--color-border-400)); + border: 1px solid rgb(var(--color-border-200)); padding: 10px 15px; vertical-align: top; box-sizing: border-box; @@ -183,8 +180,8 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { th { font-weight: bold; - text-align: left; - background-color: rgb(var(--color-primary-300)); + text-align: left; + background-color: rgb(var(--color-primary-100)); } td:hover { @@ -195,7 +192,10 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { z-index: 2; position: absolute; content: ""; - left: 0; right: 0; top: 0; bottom: 0; + left: 0; + right: 0; + top: 0; + bottom: 0; background-color: rgba(var(--color-primary-300), 0.1); pointer-events: none; } @@ -222,8 +222,8 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { } .ProseMirror table * p { - padding: 0px 1px; - margin: 6px 2px; + padding: 0px 1px; + margin: 6px 2px; } .ProseMirror table * .is-empty::before { diff --git a/apps/app/styles/globals.css b/apps/app/styles/globals.css index cdb67cbc5..3de1e2c57 100644 --- a/apps/app/styles/globals.css +++ b/apps/app/styles/globals.css @@ -355,3 +355,7 @@ body { .bp4-overlay-content { z-index: 555 !important; } + +.disable-scroll { + overflow: hidden !important; +} From b496a62540ca17f74002862857f4dea8b52f8579 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Thu, 31 Aug 2023 19:07:56 +0530 Subject: [PATCH 27/27] fix: subscribed issues are filtering (#2037) --- apiserver/plane/api/views/issue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index ac69e9d8d..cbcd40f04 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -337,7 +337,7 @@ class UserWorkSpaceIssues(BaseAPIView): issue_queryset = ( Issue.issue_objects.filter( - (Q(assignees__in=[request.user]) | Q(created_by=request.user)), + (Q(assignees__in=[request.user]) | Q(created_by=request.user) | Q(issue_subscribers__subscriber=request.user)), workspace__slug=slug, ) .annotate(