From 6b4084287c20d001122e18ea1c76f564672edd75 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Thu, 31 Aug 2023 23:24:03 +0530 Subject: [PATCH] style: plane deploy (#2039) * chore: improve access field for comments for public boards (#1956) Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> * chore: update user activity endpoint to return only workspace activities (#1980) * fix: n+1 in issue history and issue automation tasks (#1994) * fix: issue exports in self hosted instances (#1996) * fix: issue exports in self hosted instances * dev: remove print logs * dev: update url creation function * fix: changed the presigned url for self hosted exports --------- Co-authored-by: NarayanBavisetti * dev: remove gunicorn config (#1999) * feat: mark all read notifications (#1963) * feat: mark all read notifications * fix: changed string to boolean * fix: changed snoozed condition * chore: project public board issue retrieve (#2003) * chore: project public board issue retrieve * dev: project issues list endpoint * fix: issue public retrieve endpoint * fix: only external comments will show in deploy boards (#2010) * fix: issue votes (#2006) * fix: issue votes * fix: added default as 1 in vote * fix: issue vote migration file * fix: access creation in comments (#2013) * dev: user timezone select option (#2002) * fix: start date filter not working on the platform (#2007) * feat: access selector for comment (#2012) * dev: access specifier for comment * chore: change access order * style: revamp of the issue details sidebar (#2014) * chore: update module status icons and colors (#2011) * chore: update module status icons and colors * refactor: import statements * fix: add default alue to module status * chore: track public board comments and reaction users for public deploy boards (#1972) * chore: track project deploy board comment and reaction users for public deploy boards * dev: remove tracking from project viewsets * feat: user timezones (#2009) * dev: user timezones * feat: user timezones * fix: user created by stats (#2016) * fix: asset key validation (#1938) * fix: asset key validation * chore: asset key validation in user assets --------- Co-authored-by: Bavisetti Narayan * dev: revamp peek overview (#2021) * dev: mobx for issues store * refactor: peek overview component * chore: update open issue button * fix: issue mutation after any crud action * chore: remove peek overview from gantt * chore: refactor code * chore: tracking the history of issue reactions and votes. (#2020) * chore: tracking the issues reaction and vote history * fix: changed the keywords for vote and reaction * chore: added validation * dev: revamp publish project modal (#2022) * dev: revamp publish project modal * chore: sidebar dropdown text * fix: bugs on the user profile page (#2018) * chore: return issue votes in public issue list endpoint (#2026) * style: list view * [feat]: Tiptap table integration (#2008) * added basic table support * fixed table position at bottom * fixed image node deletion logic's regression issue * added compatible styles * enabled slash commands * disabled slash command and bubble menu's node selector for table cells * added dropcursor support to type below the table/image * blocked image uploads for handledrop and paste actions * style: kanban view * style: tiptap table (#2033) * style: theming added * chore: issue reactions and votes --------- Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Co-authored-by: NarayanBavisetti Co-authored-by: Bavisetti Narayan Co-authored-by: M. Palanikannan <73993394+Palanikannan1437@users.noreply.github.com> --- apiserver/Procfile | 2 +- apiserver/plane/api/serializers/__init__.py | 2 + apiserver/plane/api/serializers/issue.py | 37 +- apiserver/plane/api/serializers/project.py | 15 +- apiserver/plane/api/urls.py | 22 +- apiserver/plane/api/views/__init__.py | 5 +- apiserver/plane/api/views/asset.py | 32 +- apiserver/plane/api/views/base.py | 27 +- apiserver/plane/api/views/issue.py | 370 ++- apiserver/plane/api/views/notification.py | 93 +- apiserver/plane/api/views/project.py | 148 - apiserver/plane/api/views/user.py | 8 +- apiserver/plane/api/views/workspace.py | 2 +- apiserver/plane/bgtasks/export_task.py | 81 +- .../plane/bgtasks/exporter_expired_task.py | 29 +- .../plane/bgtasks/issue_activites_task.py | 167 +- .../plane/bgtasks/issue_automation_task.py | 8 +- ..._alter_analyticview_created_by_and_more.py | 35 + ..._together_alter_issuevote_vote_and_more.py | 26 + apiserver/plane/db/models/__init__.py | 1 + apiserver/plane/db/models/issue.py | 6 +- apiserver/plane/db/models/project.py | 15 + apiserver/plane/db/models/user.py | 7 +- apiserver/plane/settings/common.py | 4 +- .../components/command-palette/command-k.tsx | 2 +- .../views/spreadsheet-view/single-issue.tsx | 24 +- .../spreadsheet-view/spreadsheet-view.tsx | 159 +- 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/issues/comment/add-comment.tsx | 112 +- .../components/issues/gantt-chart/blocks.tsx | 4 +- apps/app/components/issues/main-content.tsx | 9 +- .../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 | 246 +- .../issues/peek-overview/side-peek-view.tsx | 83 +- .../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 +- .../components/modules/gantt-chart/blocks.tsx | 3 + apps/app/components/modules/select/status.tsx | 15 +- apps/app/components/modules/sidebar.tsx | 2 +- apps/app/components/profile/sidebar.tsx | 19 +- .../project/publish-project/modal.tsx | 674 ++--- .../project/publish-project/popover.tsx | 21 +- .../project/single-sidebar-project.tsx | 6 +- .../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 | 6 +- .../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 | 127 + apps/app/constants/module.ts | 21 +- apps/app/constants/timezones.ts | 2386 +++++++++++++++++ apps/app/contexts/profile-issues-context.tsx | 29 +- .../hooks/gantt-chart/cycle-issues-view.tsx | 1 + apps/app/hooks/gantt-chart/issue-view.tsx | 1 + .../hooks/gantt-chart/module-issues-view.tsx | 1 + apps/app/hooks/use-calendar-issues-view.tsx | 1 + apps/app/hooks/use-profile-issues.tsx | 17 +- .../app/hooks/use-spreadsheet-issues-view.tsx | 17 +- apps/app/package.json | 5 + .../[workspaceSlug]/me/profile/index.tsx | 47 +- apps/app/services/issues.service.ts | 14 +- apps/app/store/issues.ts | 172 ++ apps/app/store/project-publish.tsx | 22 +- apps/app/store/root.ts | 3 + apps/app/styles/editor.css | 81 +- apps/app/styles/globals.css | 4 + apps/app/types/issues.d.ts | 1 + apps/app/types/modules.d.ts | 10 +- apps/app/types/projects.d.ts | 2 +- apps/app/types/users.d.ts | 1 + .../issues/board-views/block-downvotes.tsx | 10 + .../issues/board-views/block-due-date.tsx | 58 +- .../issues/board-views/block-priority.tsx | 3 +- .../issues/board-views/block-state.tsx | 4 +- .../issues/board-views/block-upvotes.tsx | 8 + .../issues/board-views/kanban/block.tsx | 46 +- .../issues/board-views/kanban/header.tsx | 12 +- .../issues/board-views/kanban/index.tsx | 4 +- .../issues/board-views/list/block.tsx | 27 +- .../issues/board-views/list/header.tsx | 8 +- .../issues/board-views/list/index.tsx | 4 +- apps/space/components/issues/navbar/index.tsx | 2 +- apps/space/components/issues/navbar/theme.tsx | 3 +- .../issues/peek-overview/add-comment.tsx | 14 +- .../peek-overview/full-screen-peek-view.tsx | 51 +- .../issues/peek-overview/header.tsx | 105 +- .../issues/peek-overview/issue-activity.tsx | 10 +- .../issues/peek-overview/issue-details.tsx | 38 +- .../peek-overview/issue-emoji-reactions.tsx | 33 +- .../issues/peek-overview/issue-reaction.tsx | 1 + .../peek-overview/issue-vote-reactions.tsx | 32 +- .../issues/peek-overview/layout.tsx | 136 +- .../issues/peek-overview/side-peek-view.tsx | 32 +- apps/space/components/ui/loader.tsx | 25 + .../components/views/project-details.tsx | 22 +- apps/space/constants/data.ts | 10 +- apps/space/constants/helpers.ts | 23 + apps/space/layouts/project-layout.tsx | 8 +- apps/space/pages/_app.tsx | 1 + apps/space/store/issue.ts | 6 + apps/space/store/issue_details.ts | 35 +- apps/space/store/user.ts | 2 +- apps/space/styles/editor.css | 231 ++ apps/space/styles/globals.css | 89 +- apps/space/tailwind.config.js | 24 +- apps/space/types/issue.ts | 27 +- yarn.lock | 28 +- 131 files changed, 6147 insertions(+), 1507 deletions(-) create mode 100644 apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py create mode 100644 apiserver/plane/db/migrations/0042_alter_issuevote_unique_together_alter_issuevote_vote_and_more.py 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 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 create mode 100644 apps/app/constants/timezones.ts create mode 100644 apps/app/store/issues.ts create mode 100644 apps/space/components/issues/board-views/block-downvotes.tsx create mode 100644 apps/space/components/issues/board-views/block-upvotes.tsx create mode 100644 apps/space/components/ui/loader.tsx create mode 100644 apps/space/styles/editor.css 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 diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 5855f0413..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 @@ -44,6 +45,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 64ee2b8f7..1f4d814a4 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"] @@ -569,7 +571,7 @@ class IssueCommentSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(read_only=True, source="project") workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) - + is_member = serializers.BooleanField(read_only=True) class Meta: model = IssueComment @@ -582,7 +584,6 @@ class IssueCommentSerializer(BaseSerializer): "updated_by", "created_at", "updated_at", - "access", ] @@ -676,6 +677,32 @@ class IssueLiteSerializer(BaseSerializer): ] +class IssuePublicSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(read_only=True, source="project") + state_detail = StateLiteSerializer(read_only=True, source="state") + issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True) + votes = IssueVoteSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description_html", + "sequence_id", + "state", + "state_detail", + "project", + "project_detail", + "workspace", + "priority", + "target_date", + "issue_reactions", + "votes", + ] + read_only_fields = fields + + class IssueSubscriberSerializer(BaseSerializer): class Meta: model = IssueSubscriber 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/urls.py b/apiserver/plane/api/urls.py index a6beac693..1fb2b8e90 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -164,16 +164,18 @@ from plane.api.views import ( # Notification NotificationViewSet, UnreadNotificationEndpoint, + MarkAllReadNotificationViewSet, ## End Notification # Public Boards ProjectDeployBoardViewSet, - ProjectDeployBoardIssuesPublicEndpoint, + ProjectIssuesPublicEndpoint, ProjectDeployBoardPublicSettingsEndpoint, IssueReactionPublicViewSet, CommentReactionPublicViewSet, InboxIssuePublicViewSet, IssueVotePublicViewSet, WorkspaceProjectDeployBoardEndpoint, + IssueRetrievePublicEndpoint, ## End Public Boards ## Exporter ExportIssuesEndpoint, @@ -235,7 +237,7 @@ urlpatterns = [ UpdateUserTourCompletedEndpoint.as_view(), name="user-tour", ), - path("users/activities/", UserActivityEndpoint.as_view(), name="user-activities"), + path("users/workspaces//activities/", UserActivityEndpoint.as_view(), name="user-activities"), # user workspaces path( "users/me/workspaces/", @@ -1494,6 +1496,15 @@ urlpatterns = [ UnreadNotificationEndpoint.as_view(), name="unread-notifications", ), + path( + "workspaces//users/notifications/mark-all-read/", + MarkAllReadNotificationViewSet.as_view( + { + "post": "create", + } + ), + name="mark-all-read-notifications", + ), ## End Notification # Public Boards path( @@ -1524,9 +1535,14 @@ urlpatterns = [ ), path( "public/workspaces//project-boards//issues/", - ProjectDeployBoardIssuesPublicEndpoint.as_view(), + ProjectIssuesPublicEndpoint.as_view(), name="project-deploy-board", ), + path( + "public/workspaces//project-boards//issues//", + IssueRetrievePublicEndpoint.as_view(), + name="workspace-project-boards", + ), path( "public/workspaces//project-boards//issues//comments/", IssueCommentPublicViewSet.as_view( diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 9572c552f..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 ( @@ -162,7 +163,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/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): 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/api/views/issue.py b/apiserver/plane/api/views/issue.py index 0b08bb14f..ac69e9d8d 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, @@ -73,10 +75,12 @@ from plane.db.models import ( CommentReaction, ProjectDeployBoard, IssueVote, + ProjectPublicMember, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters +from plane.bgtasks.export_task import issue_export_task class IssueViewSet(BaseViewSet): @@ -482,7 +486,7 @@ class IssueActivityEndpoint(BaseAPIView): issue_activities = ( IssueActivity.objects.filter(issue_id=issue_id) .filter( - ~Q(field="comment"), + ~Q(field__in=["comment", "vote", "reaction"]), project__project_projectmember__member=self.request.user, ) .select_related("actor", "workspace", "issue", "project") @@ -492,6 +496,12 @@ class IssueActivityEndpoint(BaseAPIView): .filter(project__project_projectmember__member=self.request.user) .order_by("created_at") .select_related("actor", "issue", "project", "workspace") + .prefetch_related( + Prefetch( + "comment_reactions", + queryset=CommentReaction.objects.select_related("actor"), + ) + ) ) issue_activities = IssueActivitySerializer(issue_activities, many=True).data issue_comments = IssueCommentSerializer(issue_comments, many=True).data @@ -588,6 +598,15 @@ class IssueCommentViewSet(BaseViewSet): .select_related("project") .select_related("workspace") .select_related("issue") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + member_id=self.request.user.id, + ) + ) + ) .distinct() ) @@ -769,7 +788,9 @@ class SubIssuesEndpoint(BaseAPIView): .order_by("state_group") ) - result = {item["state_group"]: item["state_count"] for item in state_distribution} + result = { + item["state_group"]: item["state_count"] for item in state_distribution + } serializer = IssueLiteSerializer( sub_issues, @@ -1384,6 +1405,14 @@ class IssueReactionViewSet(BaseViewSet): project_id=self.kwargs.get("project_id"), actor=self.request.user, ) + issue_activity.delay( + type="issue_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) def destroy(self, request, slug, project_id, issue_id, reaction_code): try: @@ -1394,6 +1423,19 @@ class IssueReactionViewSet(BaseViewSet): reaction=reaction_code, actor=request.user, ) + issue_activity.delay( + type="issue_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(issue_reaction.id), + } + ), + ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) except IssueReaction.DoesNotExist: @@ -1434,6 +1476,14 @@ class CommentReactionViewSet(BaseViewSet): comment_id=self.kwargs.get("comment_id"), project_id=self.kwargs.get("project_id"), ) + issue_activity.delay( + type="comment_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) def destroy(self, request, slug, project_id, comment_id, reaction_code): try: @@ -1444,6 +1494,20 @@ class CommentReactionViewSet(BaseViewSet): reaction=reaction_code, actor=request.user, ) + issue_activity.delay( + type="comment_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(comment_reaction.id), + "comment_id": str(comment_id) + } + ), + ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) except CommentReaction.DoesNotExist: @@ -1479,9 +1543,19 @@ class IssueCommentPublicViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(issue_id=self.kwargs.get("issue_id")) + .filter(access="EXTERNAL") .select_related("project") .select_related("workspace") .select_related("issue") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + member_id=self.request.user.id, + ) + ) + ) .distinct() ) else: @@ -1499,21 +1573,13 @@ class IssueCommentPublicViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - access = ( - "INTERNAL" - if ProjectMember.objects.filter( - project_id=project_id, member=request.user - ).exists() - else "EXTERNAL" - ) - serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( project_id=project_id, issue_id=issue_id, actor=request.user, - access=access, + access="EXTERNAL", ) issue_activity.delay( type="comment.activity.created", @@ -1523,6 +1589,16 @@ class IssueCommentPublicViewSet(BaseViewSet): project_id=str(project_id), current_instance=None, ) + if not ProjectMember.objects.filter( + project_id=project_id, + member=request.user, + ).exists(): + # Add the user for workspace tracking + _ = ProjectPublicMember.objects.get_or_create( + project_id=project_id, + member=request.user, + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except Exception as e: @@ -1567,7 +1643,8 @@ class IssueCommentPublicViewSet(BaseViewSet): except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist): return Response( {"error": "IssueComent Does not exists"}, - status=status.HTTP_400_BAD_REQUEST,) + status=status.HTTP_400_BAD_REQUEST, + ) def destroy(self, request, slug, project_id, issue_id, pk): try: @@ -1648,6 +1725,23 @@ class IssueReactionPublicViewSet(BaseViewSet): serializer.save( project_id=project_id, issue_id=issue_id, actor=request.user ) + if not ProjectMember.objects.filter( + project_id=project_id, + member=request.user, + ).exists(): + # Add the user for workspace tracking + _ = ProjectPublicMember.objects.get_or_create( + project_id=project_id, + member=request.user, + ) + issue_activity.delay( + type="issue_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except ProjectDeployBoard.DoesNotExist: @@ -1679,6 +1773,19 @@ class IssueReactionPublicViewSet(BaseViewSet): reaction=reaction_code, actor=request.user, ) + issue_activity.delay( + type="issue_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(issue_reaction.id), + } + ), + ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) except IssueReaction.DoesNotExist: @@ -1733,8 +1840,29 @@ class CommentReactionPublicViewSet(BaseViewSet): serializer.save( project_id=project_id, comment_id=comment_id, actor=request.user ) + if not ProjectMember.objects.filter( + project_id=project_id, member=request.user + ).exists(): + # Add the user for workspace tracking + _ = ProjectPublicMember.objects.get_or_create( + project_id=project_id, + member=request.user, + ) + issue_activity.delay( + type="comment_reaction.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IssueComment.DoesNotExist: + return Response( + {"error": "Comment does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) except ProjectDeployBoard.DoesNotExist: return Response( {"error": "Project board does not exist"}, @@ -1765,6 +1893,20 @@ class CommentReactionPublicViewSet(BaseViewSet): reaction=reaction_code, actor=request.user, ) + issue_activity.delay( + type="comment_reaction.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "reaction": str(reaction_code), + "identifier": str(comment_reaction.id), + "comment_id": str(comment_id) + } + ), + ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) except CommentReaction.DoesNotExist: @@ -1799,8 +1941,25 @@ class IssueVotePublicViewSet(BaseViewSet): actor_id=request.user.id, project_id=project_id, issue_id=issue_id, - vote=request.data.get("vote", 1), ) + # Add the user for workspace tracking + if not ProjectMember.objects.filter( + project_id=project_id, member=request.user + ).exists(): + _ = ProjectPublicMember.objects.get_or_create( + project_id=project_id, + member=request.user, + ) + issue_vote.vote = request.data.get("vote", 1) + issue_vote.save() + issue_activity.delay( + type="issue_vote.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + ) serializer = IssueVoteSerializer(issue_vote) return Response(serializer.data, status=status.HTTP_201_CREATED) except Exception as e: @@ -1818,6 +1977,19 @@ class IssueVotePublicViewSet(BaseViewSet): issue_id=issue_id, actor_id=request.user.id, ) + issue_activity.delay( + type="issue_vote.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "vote": str(issue_vote.vote), + "identifier": str(issue_vote.id), + } + ), + ) issue_vote.delete() return Response(status=status.HTTP_204_NO_CONTENT) except Exception as e: @@ -1827,3 +1999,175 @@ class IssueVotePublicViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + +class IssueRetrievePublicEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, slug, project_id, issue_id): + try: + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=issue_id + ) + serializer = IssuePublicSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + except Issue.DoesNotExist: + return Response( + {"error": "Issue Does not exist"}, status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + print(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class ProjectIssuesPublicEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, slug, project_id): + try: + project_deploy_board = ProjectDeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + + filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", None] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = ( + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project", "workspace", "state", "parent") + .prefetch_related("assignees", "labels") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order + if order_by_param == "priority" + else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" if order_by_param.startswith("-") else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + issues = IssuePublicSerializer(issue_queryset, many=True).data + + states = State.objects.filter( + workspace__slug=slug, project_id=project_id + ).values("name", "group", "color", "id") + + labels = Label.objects.filter( + workspace__slug=slug, project_id=project_id + ).values("id", "name", "color", "parent") + + ## Grouping the results + group_by = request.GET.get("group_by", False) + if group_by: + issues = group_results(issues, group_by) + + return Response( + { + "issues": issues, + "states": states, + "labels": labels, + }, + status=status.HTTP_200_OK, + ) + except ProjectDeployBoard.DoesNotExist: + return Response( + {"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + 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, + ) 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,] 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, diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index cfdd0dd9b..cbf62548f 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, ) @@ -1198,6 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): projects = request.query_params.getlist("project", []) queryset = IssueActivity.objects.filter( + ~Q(field__in=["comment", "vote", "reaction"]), workspace__slug=slug, project__project_projectmember__member=request.user, actor=user_id, 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) 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 = [] 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: 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/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/__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/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" 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",) 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 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 = ({ deleteIssue, isPaletteOpen, setIsPal className="focus:outline-none" >
- + Join our Discord
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 = ({ }) => { 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 = ({ [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 = ({ return ( <> - handleDeleteIssue(issue)} - handleUpdateIssue={async (formData) => partialUpdateIssue(formData, issue)} - issue={issue} - isOpen={issuePeekOverview} - onClose={() => setIssuePeekOverview(false)} - workspaceSlug={workspaceSlug?.toString() ?? ""} - readOnly={isNotAllowed} - />
= ({ 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 = ({ 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 = ({ .join(" "); return ( -
-
- -
- {spreadsheetIssues ? ( -
- {spreadsheetIssues.map((issue: IIssue, index) => ( - - ))} -
- {type === "issue" ? ( - - ) : ( - !disableUserActions && ( - - - Add Issue - - } - position="left" - optionsClassName="left-5 !w-36" - noBorder - > - { - const e = new KeyboardEvent("keydown", { key: "c" }); - document.dispatchEvent(e); - }} - > - Create new - - {openIssuesListModal && ( - - Add an existing issue - - )} - - ) - )} -
+ <> + mutateIssues()} + projectId={projectId?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} + /> +
+
+
- ) : ( - - )} -
+ {spreadsheetIssues ? ( +
+ {spreadsheetIssues.map((issue: IIssue, index) => ( + + ))} +
+ {type === "issue" ? ( + + ) : ( + !disableUserActions && ( + + + Add Issue + + } + position="left" + optionsClassName="left-5 !w-36" + noBorder + > + { + const e = new KeyboardEvent("keydown", { key: "c" }); + document.dispatchEvent(e); + }} + > + Create new + + {openIssuesListModal && ( + + Add an existing issue + + )} + + ) + )} +
+
+ ) : ( + + )} +
+ ); }; 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 = ({ width = "20", height = "20", className }) => ( + + + + + + + + + + + + + + + + +); 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 = ({ + width = "20", + height = "20", + className, +}) => ( + + + + + + + + + + +); 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 = ({ + width = "20", + height = "20", + className, +}) => ( + + + +); 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 = ({ + width = "20", + height = "20", + className, +}) => ( + + + + + + + + + + + + + + + + + + + + +); 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 = ({ + status, + className, + height = "12px", + width = "12px", +}) => { + if (status === "backlog") + return ; + else if (status === "cancelled") + return ; + else if (status === "completed") + return ; + else if (status === "in-progress") + return ; + else if (status === "paused") + return ; + else return ; +}; 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 = ({ width = "20", height = "20", className }) => ( + + + + + + + + + + +); 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 = ({ width = "20", height = "20", className }) => ( + + + +); 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 = { - comment_json: "", + access: "INTERNAL", comment_html: "", }; type Props = { disabled?: boolean; onSubmit: (data: IIssueComment) => Promise; + showAccessSpecifier?: boolean; }; -export const AddComment: React.FC = ({ disabled = false, onSubmit }) => { - const { - control, - formState: { isSubmitting }, - handleSubmit, - reset, - setValue, - watch, - } = useForm({ defaultValues }); +const commentAccess = [ + { + icon: "lock", + key: "INTERNAL", + label: "Private", + }, + { + icon: "public", + key: "EXTERNAL", + label: "Public", + }, +]; +export const AddComment: React.FC = ({ + disabled = false, + onSubmit, + showAccessSpecifier = false, +}) => { const editorRef = React.useRef(null); const router = useRouter(); const { workspaceSlug } = router.query; + const { + control, + formState: { isSubmitting }, + handleSubmit, + reset, + } = useForm({ 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 = ({ disabled = false, onSubmit }) => { return (
-
- ( - { - onChange(comment_html); - setValue("comment_json", comment_json); - }} - /> +
+
+ {showAccessSpecifier && ( +
+ ( +
+ {commentAccess.map((access) => ( + + + + ))} +
+ )} + /> +
)} - /> + ( +

" : value} + customClassName="p-3 min-h-[100px] shadow-sm" + debouncedUpdatesEnabled={false} + onChange={(comment_json: Object, comment_html: string) => onChange(comment_html)} + /> + )} + /> +
{isSubmitting ? "Adding..." : "Comment"} 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 (
router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)} > @@ -49,7 +49,7 @@ export const IssueGanttSidebarBlock = ({ data }: { data: IIssue }) => { return (
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/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 = ({ 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 = ({ handleCommentUpdate={handleCommentUpdate} handleCommentDelete={handleCommentDelete} /> - +
); 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) => Promise; - issue: IIssue; + handleUpdateIssue: (formData: Partial) => Promise; + issue: IIssue | undefined; mode: TPeekOverviewModes; readOnly: boolean; setMode: (mode: TPeekOverviewModes) => void; @@ -40,39 +44,59 @@ export const FullScreenPeekView: React.FC = ({ workspaceSlug={workspaceSlug} />
-
- {/* issue title and description */} -
- + {issue ? ( +
+ {/* issue title and description */} +
+ +
+ {/* divider */} +
+ {/* issue activity/comments */} +
+ +
- {/* divider */} -
- {/* issue activity/comments */} -
- -
-
+ ) : ( + + +
+ + + +
+
+ )}
{/* issue properties */}
- + {issue ? ( + + ) : ( + + + + + + + )}
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 = ({ 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 = ({ /> )} - {mode === "modal" || mode === "full" ? ( - - ) : ( - - )} + + setMode(val)} @@ -119,7 +111,7 @@ export const PeekOverviewHeader: React.FC = ({
{(mode === "side" || mode === "modal") && ( -
+
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) => Promise; issue: IIssue; mode: TPeekOverviewModes; - onChange: (issueProperty: Partial) => void; readOnly: boolean; workspaceSlug: string; }; export const PeekOverviewIssueProperties: React.FC = ({ handleDeleteIssue, + handleUpdateIssue, issue, mode, - onChange, readOnly, workspaceSlug, }) => { @@ -86,7 +91,7 @@ export const PeekOverviewIssueProperties: React.FC = ({
onChange({ state: val })} + onChange={(val: string) => handleUpdateIssue({ state: val })} disabled={readOnly} />
@@ -99,7 +104,7 @@ export const PeekOverviewIssueProperties: React.FC = ({
onChange({ assignees_list: val })} + onChange={(val: string[]) => handleUpdateIssue({ assignees_list: val })} disabled={readOnly} />
@@ -112,7 +117,7 @@ export const PeekOverviewIssueProperties: React.FC = ({
onChange({ priority: val })} + onChange={(val: string) => handleUpdateIssue({ priority: val })} disabled={readOnly} />
@@ -128,7 +133,7 @@ export const PeekOverviewIssueProperties: React.FC = ({ placeholder="Start date" value={issue.start_date} onChange={(val) => - onChange({ + handleUpdateIssue({ start_date: val, }) } @@ -153,7 +158,7 @@ export const PeekOverviewIssueProperties: React.FC = ({ placeholder="Due date" value={issue.target_date} onChange={(val) => - onChange({ + handleUpdateIssue({ target_date: val, }) } @@ -175,7 +180,7 @@ export const PeekOverviewIssueProperties: React.FC = ({
onChange({ estimate_point: val })} + onChange={(val: number | null) =>handleUpdateIssue({ estimate_point: val })} disabled={readOnly} />
diff --git a/apps/app/components/issues/peek-overview/layout.tsx b/apps/app/components/issues/peek-overview/layout.tsx index 7196052f8..ce026e6a2 100644 --- a/apps/app/components/issues/peek-overview/layout.tsx +++ b/apps/app/components/issues/peek-overview/layout.tsx @@ -1,107 +1,183 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/router"; + +// mobx +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; // headless ui import { Dialog, Transition } from "@headlessui/react"; +// hooks +import useUser from "hooks/use-user"; +// components import { FullScreenPeekView, SidePeekView } from "components/issues"; // types import { IIssue } from "types"; type Props = { - handleDeleteIssue: () => void; - handleUpdateIssue: (issue: Partial) => Promise; - 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 = ({ - handleDeleteIssue, - handleUpdateIssue, - issue, - isOpen, - onClose, - workspaceSlug, - readOnly, -}) => { - const [peekOverviewMode, setPeekOverviewMode] = useState("side"); +export const IssuePeekOverview: React.FC = observer( + ({ handleMutation, projectId, readOnly, workspaceSlug }) => { + const [isSidePeekOpen, setIsSidePeekOpen] = useState(false); + const [isModalPeekOpen, setIsModalPeekOpen] = useState(false); + const [peekOverviewMode, setPeekOverviewMode] = useState("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 ( - - - {/* add backdrop conditionally */} - {(peekOverviewMode === "modal" || peekOverviewMode === "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) => { + if (!issue || !user) return; + + await updateIssue(workspaceSlug, projectId, issue.id, formData, user); + handleMutation(); + }; + + const handleDeleteIssue = async () => { + if (!issue || !user) return; + + await deleteIssue(workspaceSlug, projectId, issue.id, user); + handleMutation(); + + handleClose(); + }; + + useEffect(() => { + if (!peekIssue) return; + + getIssueById(workspaceSlug, projectId, peekIssue.toString()); + }, [getIssueById, peekIssue, projectId, workspaceSlug]); + + useEffect(() => { + if (peekIssue) { + if (peekOverviewMode === "side") { + setIsSidePeekOpen(true); + setIsModalPeekOpen(false); + } else { + setIsModalPeekOpen(true); + setIsSidePeekOpen(false); + } + } else { + setIsSidePeekOpen(false); + setIsModalPeekOpen(false); + } + }, [peekIssue, peekOverviewMode]); + + return ( + <> + + +
+
+ + + setPeekOverviewMode(mode)} + workspaceSlug={workspaceSlug} + /> + + +
+
+
+
+ + - - {(peekOverviewMode === "side" || peekOverviewMode === "modal") && ( - setPeekOverviewMode(mode)} - workspaceSlug={workspaceSlug} - /> - )} - {peekOverviewMode === "full" && ( - setPeekOverviewMode(mode)} - workspaceSlug={workspaceSlug} - /> - )} - +
-
-
-
-
- ); -}; +
+
+ + + {peekOverviewMode === "modal" && ( + setPeekOverviewMode(mode)} + workspaceSlug={workspaceSlug} + /> + )} + {peekOverviewMode === "full" && ( + setPeekOverviewMode(mode)} + workspaceSlug={workspaceSlug} + /> + )} + + +
+
+ + + + ); + } +); 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) => Promise; - issue: IIssue; + handleUpdateIssue: (formData: Partial) => Promise; + issue: IIssue | undefined; mode: TPeekOverviewModes; readOnly: boolean; setMode: (mode: TPeekOverviewModes) => void; @@ -39,37 +43,50 @@ export const SidePeekView: React.FC = ({ workspaceSlug={workspaceSlug} />
-
- {/* issue title and description */} -
- + {issue ? ( +
+ {/* issue title and description */} +
+ +
+ {/* issue properties */} +
+ +
+ {/* divider */} +
+ {/* issue activity/comments */} +
+ {issue && ( + + )} +
- {/* issue properties */} -
- -
- {/* divider */} -
- {/* issue activity/comments */} -
- -
-
+ ) : ( + + +
+ + + +
+
+ )}
); 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 = ({ value, onChange, disabl {value && value.length > 0 && Array.isArray(value) ? (
- {value.length} Assignees + {value.length} Assignees
) : ( - )} 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) => void; watch: UseFormWatch; - userAuth: UserAuth; disabled?: boolean; }; @@ -26,7 +25,6 @@ export const SidebarBlockedSelect: React.FC = ({ issueId, submitChanges, watch, - userAuth, disabled = false, }) => { const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); @@ -73,8 +71,6 @@ export const SidebarBlockedSelect: React.FC = ({ handleClose(); }; - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled; - return ( <> = ({
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) => void; watch: UseFormWatch; - userAuth: UserAuth; disabled?: boolean; }; @@ -26,7 +25,6 @@ export const SidebarBlockerSelect: React.FC = ({ issueId, submitChanges, watch, - userAuth, disabled = false, }) => { const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false); @@ -73,8 +71,6 @@ export const SidebarBlockerSelect: React.FC = ({ handleClose(); }; - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled; - return ( <> = ({
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 = ({ issueDetail, handleCycleChange, - userAuth, disabled = false, }) => { const router = useRouter(); @@ -63,59 +59,56 @@ export const SidebarCycleSelect: React.FC = ({ const issueCycle = issueDetail?.issue_cycle; - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled; - return ( -
-
- -

Cycle

-
-
- - - - {issueCycle ? truncateText(issueCycle.cycle_detail.name, 15) : "No cycle"} - - - - } - 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} + - {incompleteCycles ? ( - incompleteCycles.length > 0 ? ( - <> - {incompleteCycles.map((option) => ( - - - {truncateText(option.name, 25)} - - - ))} - None - - ) : ( -
No cycles found
- ) - ) : ( - - )} -
-
-
+ + + } + 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) => ( + + + {truncateText(option.name, 25)} + + + ))} + None + + ) : ( +
No cycles found
+ ) + ) : ( + + )} + ); }; 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 = ({ value, onChange, disabled = false }) => { - const { isEstimateActive, estimatePoints } = useEstimateOption(); - - if (!isEstimateActive) return null; + const { estimatePoints } = useEstimateOption(); return ( void; - userAuth: UserAuth; disabled?: boolean; }; export const SidebarModuleSelect: React.FC = ({ issueDetail, handleModuleChange, - userAuth, disabled = false, }) => { const router = useRouter(); @@ -57,66 +53,60 @@ export const SidebarModuleSelect: React.FC = ({ const issueModule = issueDetail?.issue_module; - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled; - return ( -
-
- -

Module

-
-
- m.id === issueModule?.module)?.name ?? "No module" + m.id === issueModule?.module)?.name ?? "No module" + }`} + > +
-
+ {modules?.find((m) => m.id === issueModule?.module)?.name ?? "No module"} + + + + } + 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) => ( + + + {truncateText(option.name, 25)} + + + ))} + None + + ) : ( +
No modules found
+ ) + ) : ( + + )} +
); }; 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 = ({ onChange, issueDetails, - userAuth, disabled = false, }) => { const [isParentModalOpen, setIsParentModalOpen] = useState(false); @@ -28,42 +24,34 @@ export const SidebarParentSelect: React.FC = ({ const router = useRouter(); const { projectId, issueId } = router.query; - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled; - return ( -
-
- -

Parent

-
-
- setIsParentModalOpen(false)} - onChange={(issue) => { - onChange(issue.id); - setSelectedParentIssue(issue); - }} - issueId={issueId as string} - projectId={projectId as string} - /> - -
-
+ <> + setIsParentModalOpen(false)} + onChange={(issue) => { + onChange(issue.id); + setSelectedParentIssue(issue); + }} + issueId={issueId as string} + projectId={projectId as string} + /> + + ); }; 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 = ({ value, onChange, disabl customButton={
)} - {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && ( + {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && + isEstimateActive && ( +
+
+ +

Estimate

+
+
+ ( + + submitChanges({ estimate_point: val }) + } + disabled={memberRole.isGuest || memberRole.isViewer || uneditable} + /> + )} + /> +
+
+ )} +
+ )} + {showSecondSection && ( +
+ {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
- -

Estimate

+ +

Parent

( - - submitChanges({ estimate_point: val }) - } + name="parent" + render={({ field: { onChange } }) => ( + { + submitChanges({ parent: val }); + onChange(val); + }} + issueDetails={issueDetail} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} /> )} @@ -426,34 +461,12 @@ export const IssueDetailsSidebar: React.FC = ({
)} -
- )} - {showSecondSection && ( -
- {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( - ( - { - submitChanges({ parent: val }); - onChange(val); - }} - issueDetails={issueDetail} - userAuth={memberRole} - disabled={uneditable} - /> - )} - /> - )} {(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && ( )} {(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && ( @@ -461,8 +474,7 @@ export const IssueDetailsSidebar: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ {showThirdSection && (
{(fieldsToShow.includes("all") || fieldsToShow.includes("cycle")) && ( - +
+
+ +

Cycle

+
+
+ +
+
)} {(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && ( - +
+
+ +

Module

+
+
+ +
+
)}
)} 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}`)} > +
{data.name}
); 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 = ({ control, error }) => ( }`} > {value ? ( - s.value === value)?.color, - }} - /> + ) : ( = ({ control, error }) => ( {MODULE_STATUS.map((status) => (
- + {status.label}
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 = { members_list: [], start_date: null, target_date: null, - status: null, + status: "backlog", }; type Props = { 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 ( -
+
{userProjectsData ? ( <>
@@ -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 ( = { +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 = { 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 = 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({ + } = useForm({ 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 = 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 = 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 ( @@ -270,200 +324,190 @@ export const PublishProjectModal: React.FC = observer(() => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - - {/* heading */} -
-
Publish
- {projectPublish.loader && ( -
Changes saved
- )} -
- close -
-
- - {/* content */} -
- {watch("id") && ( -
-
- - radio_button_checked - -
-
This project is live on web
-
- )} - -
-
- {`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`} -
- - -
-
-
Views
-
- 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) => ( -
{ - 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); - }} - > -
{_view.value}
-
- {watch("views") && - watch("views").length > 0 && - watch("views").includes(_view.key) && ( - - done - - )} -
-
- ))} - -
-
-
- - {/*
-
Allow comments
-
- { - const _comments = !watch("comments"); - setValue("comments", _comments); - if (watch("id") != null) onSettingsUpdate("comments", _comments); - }} - size="sm" - /> -
-
*/} - - {/*
-
Allow reactions
-
- { - const _reactions = !watch("reactions"); - setValue("reactions", _reactions); - if (watch("id") != null) onSettingsUpdate("reactions", _reactions); - }} - size="sm" - /> -
-
*/} - - {/*
-
Allow Voting
-
- { - const _votes = !watch("votes"); - setValue("votes", _votes); - if (watch("id") != null) onSettingsUpdate("votes", _votes); - }} - size="sm" - /> -
-
*/} - - {/*
-
Allow issue proposals
-
- { - setValue("inbox", !watch("inbox")); - }} - size="sm" - /> -
-
*/} -
-
- - {/* modal handlers */} -
-
-
- public -
-
Anyone with the link can access
-
-
- Cancel - {watch("id") != null ? ( - - {isSubmitting ? "Unpublishing..." : "Unpublish"} - - ) : ( - - {isSubmitting ? "Publishing..." : "Publish"} - + {isUnpublishing ? "Unpublishing..." : "Unpublish"} + )}
-
+ + {/* content */} +
+
+
+ {`${plane_deploy_url}/${workspaceSlug}/${projectPublish.project_id}`} +
+
+ +
+
+ + {watch("id") && ( +
+
+ +
+
This project is live on web
+
+ )} + +
+
+
Views
+ ( + 0 + ? viewOptions + .filter((v) => value.includes(v.key)) + .map((v) => v.label) + .join(", ") + : `` + } + placeholder="Select views" + > + <> + {viewOptions.map((option) => ( +
{ + 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(); + }} + > +
{option.label}
+
+ {value.length > 0 && value.includes(option.key) && ( + + )} +
+
+ ))} + +
+ )} + /> +
+ +
+
Allow comments
+ ( + { + onChange(val); + checkIfUpdateIsRequired(); + }} + size="sm" + /> + )} + /> +
+
+
Allow reactions
+ ( + { + onChange(val); + checkIfUpdateIsRequired(); + }} + size="sm" + /> + )} + /> +
+
+
Allow voting
+ ( + { + onChange(val); + checkIfUpdateIsRequired(); + }} + size="sm" + /> + )} + /> +
+ + {/*
+
Allow issue proposals
+ ( + + )} + /> +
*/} +
+
+ + {/* modal handlers */} +
+
+ +
Anyone with the link can access
+
+
+ Cancel + {watch("id") ? ( + <> + {isUpdateRequired && ( + + {isSubmitting ? "Updating..." : "Update settings"} + + )} + + ) : ( + + {isSubmitting ? "Publishing..." : "Publish"} + + )} +
+
+
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 }) => ( <> -
- {label ? label : placeholder ? placeholder : "Select"} -
-
+
{label ?? placeholder}
+
{!open ? ( - expand_more + ) : ( - expand_less + )}
@@ -41,8 +40,8 @@ export const CustomPopover = ({ leaveFrom="opacity-100 translate-y-0" leaveTo="opacity-0 translate-y-1" > - -
+ +
{children}
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 = observer( >
- ios_share +
-
Publish
+
{project.is_deployed ? "Publish settings" : "Publish"}
- {/* */} )} 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 = (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" > - { setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsLinkSelectorOpen(false); }} - /> + />} >; + setIsOpen: Dispatch> } @@ -52,7 +52,8 @@ export const LinkSelector: FC = ({ 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..2ab6bf288 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; @@ -75,8 +76,8 @@ const Tiptap = (props: ITipTapRichTextEditor) => { const editorClassNames = `relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md ${noBorder ? "" : "border border-custom-border-200"} ${ - borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0" - } ${customClassName}`; + borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0" + } ${customClassName}`; if (!editor) return null; editorRef.current = editor; @@ -92,6 +93,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => { {editor && }
+ {editor?.isActive("image") && }
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: , + 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..0da68410e --- /dev/null +++ b/apps/app/components/tiptap/table-menu/index.tsx @@ -0,0 +1,127 @@ +import { useState, useEffect } from "react"; +import { Rows, Columns, ToggleRight } from "lucide-react"; +import { cn } from "../utils"; +import { Tooltip } from "components/ui"; + +interface TableMenuItem { + command: () => void; + icon: any; + key: string; + name: string; +} + +export const findTableAncestor = (node: Node | null): HTMLTableElement | null => { + while (node !== null && node.nodeName !== "TABLE") { + node = node.parentNode; + } + return node as HTMLTableElement; +}; + +export const TableMenu = ({ editor }: { editor: any }) => { + const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 }); + const isOpen = editor?.isActive("table"); + + const items: TableMenuItem[] = [ + { + command: () => editor.chain().focus().addColumnBefore().run(), + icon: Columns, + key: "insert-column-right", + name: "Insert 1 column right", + }, + { + command: () => editor.chain().focus().addRowAfter().run(), + icon: Rows, + key: "insert-row-below", + name: "Insert 1 row below", + }, + { + command: () => editor.chain().focus().deleteColumn().run(), + icon: Columns, + key: "delete-column", + name: "Delete column", + }, + { + command: () => editor.chain().focus().deleteRow().run(), + icon: Rows, + key: "delete-row", + name: "Delete row", + }, + { + command: () => editor.chain().focus().toggleHeaderRow().run(), + icon: ToggleRight, + key: "toggle-header-row", + name: "Toggle header row", + }, + ]; + + useEffect(() => { + if (!window) return; + + const handleWindowClick = () => { + const selection: any = window?.getSelection(); + + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + const tableNode = findTableAncestor(range.startContainer); + + let parent = tableNode?.parentElement; + + if (tableNode) { + const tableRect = tableNode.getBoundingClientRect(); + const tableCenter = tableRect.left + tableRect.width / 2; + const menuWidth = 45; + const menuLeft = tableCenter - menuWidth / 2; + const tableBottom = tableRect.bottom; + + setTableLocation({ bottom: tableBottom, left: menuLeft }); + + while (parent) { + if (!parent.classList.contains("disable-scroll")) + parent.classList.add("disable-scroll"); + parent = parent.parentElement; + } + } else { + const scrollDisabledContainers = document.querySelectorAll(".disable-scroll"); + + scrollDisabledContainers.forEach((container) => { + container.classList.remove("disable-scroll"); + }); + } + } + }; + + window.addEventListener("click", handleWindowClick); + + return () => { + window.removeEventListener("click", handleWindowClick); + }; + }, [tableLocation, editor]); + + return ( +
+ {items.map((item, index) => ( + + + + ))} +
+ ); +}; 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/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/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/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-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; diff --git a/apps/app/hooks/use-spreadsheet-issues-view.tsx b/apps/app/hooks/use-spreadsheet-issues-view.tsx index 797ddf7d6..145313dac 100644 --- a/apps/app/hooks/use-spreadsheet-issues-view.tsx +++ b/apps/app/hooks/use-spreadsheet-issues-view.tsx @@ -43,10 +43,12 @@ const useSpreadsheetIssuesView = () => { type: filters?.type ? filters?.type : undefined, labels: filters?.labels ? filters?.labels.join(",") : undefined, created_by: filters?.created_by ? filters?.created_by.join(",") : undefined, + start_date: filters?.start_date ? filters?.start_date.join(",") : undefined, + target_date: filters?.target_date ? filters?.target_date.join(",") : undefined, sub_issue: "false", }; - const { data: projectSpreadsheetIssues } = useSWR( + const { data: projectSpreadsheetIssues, mutate: mutateProjectSpreadsheetIssues } = useSWR( workspaceSlug && projectId ? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params) : null, @@ -56,7 +58,7 @@ const useSpreadsheetIssuesView = () => { : null ); - const { data: cycleSpreadsheetIssues } = useSWR( + const { data: cycleSpreadsheetIssues, mutate: mutateCycleSpreadsheetIssues } = useSWR( workspaceSlug && projectId && cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) : null, @@ -71,7 +73,7 @@ const useSpreadsheetIssuesView = () => { : null ); - const { data: moduleSpreadsheetIssues } = useSWR( + const { data: moduleSpreadsheetIssues, mutate: mutateModuleSpreadsheetIssues } = useSWR( workspaceSlug && projectId && moduleId ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) : null, @@ -86,7 +88,7 @@ const useSpreadsheetIssuesView = () => { : null ); - const { data: viewSpreadsheetIssues } = useSWR( + const { data: viewSpreadsheetIssues, mutate: mutateViewSpreadsheetIssues } = useSWR( workspaceSlug && projectId && viewId && params ? VIEW_ISSUES(viewId.toString(), params) : null, workspaceSlug && projectId && viewId && params ? () => @@ -104,6 +106,13 @@ const useSpreadsheetIssuesView = () => { return { issueView, + mutateIssues: cycleId + ? mutateCycleSpreadsheetIssues + : moduleId + ? mutateModuleSpreadsheetIssues + : viewId + ? mutateViewSpreadsheetIssues + : mutateProjectSpreadsheetIssues, spreadsheetIssues: spreadsheetIssues ?? [], orderBy, setOrderBy, 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/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 = { avatar: "", @@ -31,6 +39,7 @@ const defaultValues: Partial = { 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 ( { {errors.role && Please select a role} +
+
+

Timezone

+

Select a timezone

+
+
+ ( + t.value === value)?.label ?? value + : "Select a timezone" + } + options={timeZoneOptions} + onChange={onChange} + verticalPosition="top" + optionsClassName="w-full" + input + /> + )} + /> + {errors.role && Please select a role} +
+
{isSubmitting ? "Updating..." : "Update profile"} 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, user: ICurrentUserResponse | undefined ): Promise { return this.post( @@ -468,20 +468,18 @@ class ProjectIssuesServices extends APIService { metadata: any; title: string; url: string; - }, - + } ): Promise { 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/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 => { + 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 => { + 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, + 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/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 { 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); } } diff --git a/apps/app/styles/editor.css b/apps/app/styles/editor.css index 57c23c911..9da250dd1 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 { @@ -140,7 +144,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { height: 20px; border-radius: 50%; border: 3px solid rgba(var(--color-text-200)); - border-top-color: rgba(var(--color-text-800)); + border-top-color: rgba(var(--color-text-800)); animation: spinning 0.6s linear infinite; } } @@ -150,3 +154,78 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { transform: rotate(360deg); } } + +#tiptap-container { + table { + border-collapse: collapse; + table-layout: fixed; + margin: 0; + border: 1px solid rgb(var(--color-border-200)); + width: 100%; + + td, + th { + min-width: 1em; + border: 1px solid rgb(var(--color-border-200)); + padding: 10px 15px; + vertical-align: top; + box-sizing: border-box; + position: relative; + transition: background-color 0.3s ease; + + > * { + margin-bottom: 0; + } + } + + th { + font-weight: bold; + text-align: left; + background-color: rgb(var(--color-primary-100)); + } + + td:hover { + background-color: rgba(var(--color-primary-300), 0.1); + } + + .selectedCell:after { + z-index: 2; + position: absolute; + content: ""; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: rgba(var(--color-primary-300), 0.1); + pointer-events: none; + } + + .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: -2px; + width: 2px; + background-color: rgb(var(--color-primary-400)); + pointer-events: none; + } + } +} + +.tableWrapper { + overflow-x: auto; +} + +.resize-cursor { + cursor: ew-resize; + cursor: col-resize; +} + +.ProseMirror table * p { + padding: 0px 1px; + margin: 6px 2px; +} + +.ProseMirror table * .is-empty::before { + opacity: 0; +} 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; +} 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/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; 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 { 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; } diff --git a/apps/space/components/issues/board-views/block-downvotes.tsx b/apps/space/components/issues/board-views/block-downvotes.tsx new file mode 100644 index 000000000..72e21f641 --- /dev/null +++ b/apps/space/components/issues/board-views/block-downvotes.tsx @@ -0,0 +1,10 @@ +"use client"; + +export const IssueBlockDownVotes = ({ number }: { number: number }) => ( +
+ + arrow_upward_alt + + {number} +
+); diff --git a/apps/space/components/issues/board-views/block-due-date.tsx b/apps/space/components/issues/board-views/block-due-date.tsx index c653f0e82..f96836098 100644 --- a/apps/space/components/issues/board-views/block-due-date.tsx +++ b/apps/space/components/issues/board-views/block-due-date.tsx @@ -1,32 +1,60 @@ "use client"; // helpers -import { renderDateFormat } from "constants/helpers"; +import { renderFullDate } from "constants/helpers"; export const findHowManyDaysLeft = (date: string | Date) => { const today = new Date(); const eventDate = new Date(date); const timeDiff = Math.abs(eventDate.getTime() - today.getTime()); + return Math.ceil(timeDiff / (1000 * 3600 * 24)); }; -const validDate = (date: any, state: string): string => { - if (date === null || ["backlog", "unstarted", "cancelled"].includes(state)) - return `bg-gray-500/10 text-gray-500 border-gray-500/50`; - else { +const dueDateIcon = ( + date: string, + stateGroup: string +): { + iconName: string; + className: string; +} => { + let iconName = "calendar_today"; + let className = ""; + + if (!date || ["completed", "cancelled"].includes(stateGroup)) { + iconName = "calendar_today"; + className = ""; + } else { const today = new Date(); const dueDate = new Date(date); - if (dueDate < today) return `bg-red-500/10 text-red-500 border-red-500/50`; - else return `bg-green-500/10 text-green-500 border-green-500/50`; + if (dueDate < today) { + iconName = "event_busy"; + className = "text-red-500"; + } else if (dueDate > today) { + iconName = "calendar_today"; + className = ""; + } else { + iconName = "today"; + className = "text-red-500"; + } } + + return { + iconName, + className, + }; }; -export const IssueBlockDueDate = ({ due_date, state }: any) => ( -
- {renderDateFormat(due_date)} -
-); +export const IssueBlockDueDate = ({ due_date, group }: { due_date: string; group: string }) => { + const iconDetails = dueDateIcon(due_date, group); + + return ( +
+ + {iconDetails.iconName} + + {renderFullDate(due_date)} +
+ ); +}; diff --git a/apps/space/components/issues/board-views/block-priority.tsx b/apps/space/components/issues/board-views/block-priority.tsx index 9e6729409..5c9ad3c08 100644 --- a/apps/space/components/issues/board-views/block-priority.tsx +++ b/apps/space/components/issues/board-views/block-priority.tsx @@ -9,8 +9,9 @@ export const IssueBlockPriority = ({ priority }: { priority: TIssuePriorityKey | const priority_detail = priority != null ? issuePriorityFilter(priority) : null; if (priority_detail === null) return <>; + return ( -
+
{priority_detail?.icon}
); diff --git a/apps/space/components/issues/board-views/block-state.tsx b/apps/space/components/issues/board-views/block-state.tsx index b422af027..6638566f9 100644 --- a/apps/space/components/issues/board-views/block-state.tsx +++ b/apps/space/components/issues/board-views/block-state.tsx @@ -8,8 +8,8 @@ export const IssueBlockState = ({ state }: any) => { if (stateGroup === null) return <>; return ( -
-
+
+
{state?.name}
diff --git a/apps/space/components/issues/board-views/block-upvotes.tsx b/apps/space/components/issues/board-views/block-upvotes.tsx new file mode 100644 index 000000000..517afeaf0 --- /dev/null +++ b/apps/space/components/issues/board-views/block-upvotes.tsx @@ -0,0 +1,8 @@ +"use client"; + +export const IssueBlockUpVotes = ({ number }: { number: number }) => ( +
+ arrow_upward_alt + {number} +
+); diff --git a/apps/space/components/issues/board-views/kanban/block.tsx b/apps/space/components/issues/board-views/kanban/block.tsx index 9bdb8d934..07375fe36 100644 --- a/apps/space/components/issues/board-views/kanban/block.tsx +++ b/apps/space/components/issues/board-views/kanban/block.tsx @@ -12,29 +12,45 @@ import { IssueBlockDueDate } from "components/issues/board-views/block-due-date" // interfaces import { IIssue } from "types/issue"; import { RootStore } from "store/root"; +import { useRouter } from "next/router"; export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => { - const store: RootStore = useMobxStore(); + const { project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore(); - const { issue: issueStore } = store; + // router + const router = useRouter(); + const { workspace_slug, project_slug, board } = router.query; + + const handleBlockClick = () => { + issueDetailStore.setPeekId(issue.id); + router.replace( + { + pathname: `/${workspace_slug?.toString()}/${project_slug}`, + query: { + board: board?.toString(), + peekId: issue.id, + }, + }, + undefined, + { shallow: true } + ); + // router.push(`/${workspace_slug?.toString()}/${project_slug}?board=${board?.toString()}&peekId=${issue.id}`); + }; return ( -
+
{/* id */} -
- {store?.project?.project?.identifier}-{issue?.sequence_id} +
+ {projectStore?.project?.identifier}-{issue?.sequence_id}
{/* name */} -
issueStore?.setActivePeekOverviewIssueId(issue?.id)} - className="text-custom-text-100 text-sm font-medium h-full break-words line-clamp-2 cursor-pointer" - > +
{issue.name} -
+ - {/* priority */} -
+
+ {/* priority */} {issue?.priority && (
@@ -46,12 +62,6 @@ export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => {
)} - {/* labels */} - {issue?.label_details && issue?.label_details.length > 0 && ( -
- -
- )} {/* due date */} {issue?.target_date && (
diff --git a/apps/space/components/issues/board-views/kanban/header.tsx b/apps/space/components/issues/board-views/kanban/header.tsx index 8101f10a9..69c252593 100644 --- a/apps/space/components/issues/board-views/kanban/header.tsx +++ b/apps/space/components/issues/board-views/kanban/header.tsx @@ -18,14 +18,14 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { if (stateGroup === null) return <>; return ( -
-
+
+
-
{state?.name}
-
- {/* {store.issue.getCountOfIssuesByState(state.id)} */} -
+
{state?.name}
+ + {store.issue.getCountOfIssuesByState(state.id)} +
); }); diff --git a/apps/space/components/issues/board-views/kanban/index.tsx b/apps/space/components/issues/board-views/kanban/index.tsx index ceeb8b45e..2ba1450a0 100644 --- a/apps/space/components/issues/board-views/kanban/index.tsx +++ b/apps/space/components/issues/board-views/kanban/index.tsx @@ -23,10 +23,10 @@ export const IssueKanbanView = observer(() => {
-
+
{store.issue.getFilteredIssuesByState(_state.id) && store.issue.getFilteredIssuesByState(_state.id).length > 0 ? ( -
+
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( ))} diff --git a/apps/space/components/issues/board-views/list/block.tsx b/apps/space/components/issues/board-views/list/block.tsx index bbac97741..63e8eaedd 100644 --- a/apps/space/components/issues/board-views/list/block.tsx +++ b/apps/space/components/issues/board-views/list/block.tsx @@ -6,6 +6,8 @@ import { IssueBlockPriority } from "components/issues/board-views/block-priority import { IssueBlockState } from "components/issues/board-views/block-state"; import { IssueBlockLabels } from "components/issues/board-views/block-labels"; import { IssueBlockDueDate } from "components/issues/board-views/block-due-date"; +import { IssueBlockUpVotes } from "components/issues/board-views/block-upvotes"; +import { IssueBlockDownVotes } from "components/issues/board-views/block-downvotes"; // mobx hook import { useMobxStore } from "lib/mobx/store-provider"; // interfaces @@ -37,22 +39,33 @@ export const IssueListBlock: FC<{ issue: IIssue }> = observer((props) => { // router.push(`/${workspace_slug?.toString()}/${project_slug}?board=${board?.toString()}&peekId=${issue.id}`); }; + const totalUpVotes = issue.votes.filter((v) => v.vote === 1); + const totalDownVotes = issue.votes.filter((v) => v.vote === -1); + return ( -
-
+
+
{/* id */} -
+
{projectStore?.project?.identifier}-{issue?.sequence_id}
{/* name */} -
-

- {issue.name} -

+
+ {issue.name}
+ {/* upvotes */} +
+ +
+ + {/* downotes */} +
+ +
+ {/* priority */} {issue?.priority && (
diff --git a/apps/space/components/issues/board-views/list/header.tsx b/apps/space/components/issues/board-views/list/header.tsx index 04ad0e7ca..546c20bf6 100644 --- a/apps/space/components/issues/board-views/list/header.tsx +++ b/apps/space/components/issues/board-views/list/header.tsx @@ -18,12 +18,12 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { if (stateGroup === null) return <>; return ( -
-
+
+
-
{state?.name}
- {/*
{store.issue.getCountOfIssuesByState(state.id)}
*/} +
{state?.name}
+
{store.issue.getCountOfIssuesByState(state.id)}
); }); diff --git a/apps/space/components/issues/board-views/list/index.tsx b/apps/space/components/issues/board-views/list/index.tsx index 8d31199b0..4d4701840 100644 --- a/apps/space/components/issues/board-views/list/index.tsx +++ b/apps/space/components/issues/board-views/list/index.tsx @@ -23,13 +23,13 @@ export const IssueListView = observer(() => { {issueStore.getFilteredIssuesByState(_state.id) && issueStore.getFilteredIssuesByState(_state.id).length > 0 ? ( -
+
{issueStore.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( ))}
) : ( -
+
No Issues are available.
)} diff --git a/apps/space/components/issues/navbar/index.tsx b/apps/space/components/issues/navbar/index.tsx index ce7120864..684d20451 100644 --- a/apps/space/components/issues/navbar/index.tsx +++ b/apps/space/components/issues/navbar/index.tsx @@ -52,7 +52,7 @@ const IssueNavbar = observer(() => {
{/* project detail */}
-
+
{projectStore?.project && projectStore?.project?.emoji ? ( renderEmoji(projectStore?.project?.emoji) ) : ( diff --git a/apps/space/components/issues/navbar/theme.tsx b/apps/space/components/issues/navbar/theme.tsx index 2e40af8a3..81762b5d2 100644 --- a/apps/space/components/issues/navbar/theme.tsx +++ b/apps/space/components/issues/navbar/theme.tsx @@ -27,10 +27,9 @@ export const NavbarTheme = observer(() => { document?.documentElement.setAttribute("data-theme", theme ?? store?.theme?.theme); }, [theme, store]); - // TODO: check these styles return (
{theme === "light" ? ( diff --git a/apps/space/components/issues/peek-overview/add-comment.tsx b/apps/space/components/issues/peek-overview/add-comment.tsx index 52c3b997f..c38bacad3 100644 --- a/apps/space/components/issues/peek-overview/add-comment.tsx +++ b/apps/space/components/issues/peek-overview/add-comment.tsx @@ -14,7 +14,6 @@ import { Comment } from "types"; import { TipTapEditor } from "components/tiptap"; const defaultValues: Partial = { - comment_json: "", comment_html: "", }; @@ -37,7 +36,7 @@ export const AddComment: React.FC = observer((props) => { const router = useRouter(); const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - const { issue: issueStore, user: userStore, issueDetails: issueDetailStore } = useMobxStore(); + const { user: userStore, issueDetails: issueDetailStore } = useMobxStore(); const issueId = issueDetailStore.peekId; @@ -46,15 +45,7 @@ export const AddComment: React.FC = observer((props) => { const { setToastAlert } = useToast(); const onSubmit = async (formData: Comment) => { - if ( - !workspace_slug || - !project_slug || - !issueId || - isSubmitting || - !formData.comment_html || - !formData.comment_json - ) - return; + if (!workspace_slug || !project_slug || !issueId || isSubmitting || !formData.comment_html) return; await issueDetailStore .addIssueComment(workspace_slug, project_slug, issueId, formData) @@ -90,7 +81,6 @@ export const AddComment: React.FC = observer((props) => { debouncedUpdatesEnabled={false} onChange={(comment_json: Object, comment_html: string) => { onChange(comment_html); - setValue("comment_json", comment_json); }} /> )} diff --git a/apps/space/components/issues/peek-overview/full-screen-peek-view.tsx b/apps/space/components/issues/peek-overview/full-screen-peek-view.tsx index 291bcc887..1e283138f 100644 --- a/apps/space/components/issues/peek-overview/full-screen-peek-view.tsx +++ b/apps/space/components/issues/peek-overview/full-screen-peek-view.tsx @@ -10,41 +10,60 @@ import { PeekOverviewIssueProperties, } from "components/issues/peek-overview"; // types -import { IIssue } from "types/issue"; +import { Loader } from "components/ui/loader"; +import { IIssue } from "types"; type Props = { handleClose: () => void; - issueDetails: IIssue; + issueDetails: IIssue | undefined; }; export const FullScreenPeekView: React.FC = observer((props) => { const { handleClose, issueDetails } = props; - const { issueDetails: issueDetailStore } = useMobxStore(); - return (
-
- {/* issue title and description */} -
- + {issueDetails ? ( +
+ {/* issue title and description */} +
+ +
+ {/* divider */} +
+ {/* issue activity/comments */} +
+ +
- {/* divider */} -
- {/* issue activity/comments */} -
- -
-
+ ) : ( + + +
+ + + +
+
+ )}
{/* issue properties */}
- + {issueDetails ? ( + + ) : ( + + + + + + + )}
diff --git a/apps/space/components/issues/peek-overview/header.tsx b/apps/space/components/issues/peek-overview/header.tsx index b809f5438..b397f2b5b 100644 --- a/apps/space/components/issues/peek-overview/header.tsx +++ b/apps/space/components/issues/peek-overview/header.tsx @@ -16,7 +16,7 @@ import { IIssue } from "types"; type Props = { handleClose: () => void; - issueDetails: IIssue; + issueDetails: IIssue | undefined; }; const peekModes: { @@ -38,19 +38,19 @@ const peekModes: { ]; export const PeekOverviewHeader: React.FC = (props) => { - const { issueDetails, handleClose } = props; + const { handleClose, issueDetails } = props; const { issueDetails: issueDetailStore }: RootStore = useMobxStore(); const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspace_slug } = router.query; const { setToastAlert } = useToast(); const handleCopyLink = () => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${issueDetails.project}/`).then(() => { + copyTextToClipboard(`${originURL}/${workspace_slug}/projects/${issueDetails?.project}/`).then(() => { setToastAlert({ type: "success", title: "Link copied!", @@ -60,53 +60,58 @@ export const PeekOverviewHeader: React.FC = (props) => { }; return ( -
-
- {issueDetailStore.peekMode === "side" && ( - + )} + {/* + + + + */} + {/* setMode(val)} + customButton={ + + } + position="left" > - - - )} - {issueDetailStore.peekMode === "modal" || issueDetailStore.peekMode === "full" ? ( - - ) : ( - - )} - -
- {(issueDetailStore.peekMode === "side" || issueDetailStore.peekMode === "modal") && ( -
- + {peekModes.map((mode) => ( + +
+ + {mode.label} +
+
+ ))} + */}
- )} -
+ {(issueDetailStore.peekMode === "side" || issueDetailStore.peekMode === "modal") && ( +
+ +
+ )} +
+ ); }; diff --git a/apps/space/components/issues/peek-overview/issue-activity.tsx b/apps/space/components/issues/peek-overview/issue-activity.tsx index 0302629f4..d1ee683ad 100644 --- a/apps/space/components/issues/peek-overview/issue-activity.tsx +++ b/apps/space/components/issues/peek-overview/issue-activity.tsx @@ -16,7 +16,7 @@ export const PeekOverviewIssueActivity: React.FC = observer((props) => { const router = useRouter(); const { workspace_slug } = router.query; - const { issueDetails: issueDetailStore, user: userStore } = useMobxStore(); + const { issueDetails: issueDetailStore, project: projectStore, user: userStore } = useMobxStore(); const comments = issueDetailStore.details[issueDetailStore.peekId || ""]?.comments || []; @@ -30,9 +30,11 @@ export const PeekOverviewIssueActivity: React.FC = observer((props) => { ))}
-
- -
+ {projectStore.deploySettings?.comments && ( +
+ +
+ )}
)}
diff --git a/apps/space/components/issues/peek-overview/issue-details.tsx b/apps/space/components/issues/peek-overview/issue-details.tsx index ff725182d..ebdab3521 100644 --- a/apps/space/components/issues/peek-overview/issue-details.tsx +++ b/apps/space/components/issues/peek-overview/issue-details.tsx @@ -1,4 +1,6 @@ import { IssueReactions } from "components/issues/peek-overview"; +import { TipTapEditor } from "components/tiptap"; +import { useRouter } from "next/router"; // types import { IIssue } from "types"; @@ -6,12 +8,30 @@ type Props = { issueDetails: IIssue; }; -export const PeekOverviewIssueDetails: React.FC = ({ issueDetails }) => ( -
-
- {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} -
-

{issueDetails.name}

- -
-); +export const PeekOverviewIssueDetails: React.FC = ({ issueDetails }) => { + const router = useRouter(); + const { workspace_slug } = router.query; + + return ( +
+
+ {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} +
+

{issueDetails.name}

+

" + : issueDetails.description_html + } + customClassName="p-3 min-h-[50px] shadow-sm" + debouncedUpdatesEnabled={false} + editable={false} + /> + +
+ ); +}; diff --git a/apps/space/components/issues/peek-overview/issue-emoji-reactions.tsx b/apps/space/components/issues/peek-overview/issue-emoji-reactions.tsx index 04bd9f63b..b56d4649d 100644 --- a/apps/space/components/issues/peek-overview/issue-emoji-reactions.tsx +++ b/apps/space/components/issues/peek-overview/issue-emoji-reactions.tsx @@ -1,5 +1,6 @@ -import { useEffect } from "react"; import { useRouter } from "next/router"; + +// mobx import { observer } from "mobx-react-lite"; // lib import { useMobxStore } from "lib/mobx/store-provider"; @@ -7,32 +8,44 @@ import { useMobxStore } from "lib/mobx/store-provider"; import { groupReactions, renderEmoji } from "helpers/emoji.helper"; // components import { ReactionSelector } from "components/ui"; +import { useEffect } from "react"; export const IssueEmojiReactions: React.FC = observer(() => { // router const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; + const { workspace_slug, project_slug } = router.query; // store - const { user: userStore, issue: issueStore, issueDetails: issueDetailsStore } = useMobxStore(); + const { user: userStore, issueDetails: issueDetailsStore } = useMobxStore(); const user = userStore?.currentUser; const issueId = issueDetailsStore.peekId; const reactions = issueId ? issueDetailsStore.details[issueId]?.reactions || [] : []; const groupedReactions = groupReactions(reactions, "reaction"); - const handleReactionClick = (reactionHexa: string) => { + const handleReactionClick = (reactionHex: string) => { if (!workspace_slug || !project_slug || !issueId) return; - const userReaction = reactions?.find((r: any) => r.created_by === user?.id && r.reaction === reactionHexa); + const userReaction = reactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex); if (userReaction) - issueStore.deleteIssueReactionAsync(workspace_slug, userReaction.project, userReaction.issue, reactionHexa); + issueDetailsStore.removeIssueReaction( + workspace_slug.toString(), + project_slug.toString(), + userReaction.issue, + reactionHex + ); else - issueStore.createIssueReactionAsync(workspace_slug, project_slug, issueId, { - reaction: reactionHexa, + issueDetailsStore.addIssueReaction(workspace_slug.toString(), project_slug.toString(), issueId, { + reaction: reactionHex, }); }; + useEffect(() => { + if (user) return; + + userStore.fetchCurrentUser(); + }, [user, userStore]); + return ( <> { }} key={reaction} className={`flex items-center gap-1 text-custom-text-100 text-sm h-full px-2 py-1 rounded-md border ${ - reactions?.some((r: any) => r.actor === user?.id && r.reaction === reaction) + reactions?.some((r) => r.actor_detail.id === user?.id && r.reaction === reaction) ? "bg-custom-primary-100/10 border-custom-primary-100" : "bg-custom-background-80 border-transparent" }`} @@ -64,7 +77,7 @@ export const IssueEmojiReactions: React.FC = observer(() => { {renderEmoji(reaction)} r.actor === user?.id && r.reaction === reaction) + reactions?.some((r) => r.actor_detail.id === user?.id && r.reaction === reaction) ? "text-custom-primary-100" : "" } diff --git a/apps/space/components/issues/peek-overview/issue-reaction.tsx b/apps/space/components/issues/peek-overview/issue-reaction.tsx index fc1b15e51..790b5ccae 100644 --- a/apps/space/components/issues/peek-overview/issue-reaction.tsx +++ b/apps/space/components/issues/peek-overview/issue-reaction.tsx @@ -3,6 +3,7 @@ import { useMobxStore } from "lib/mobx/store-provider"; export const IssueReactions: React.FC = () => { const { project: projectStore } = useMobxStore(); + return (
{projectStore?.deploySettings?.votes && ( diff --git a/apps/space/components/issues/peek-overview/issue-vote-reactions.tsx b/apps/space/components/issues/peek-overview/issue-vote-reactions.tsx index 77653614e..dace360d4 100644 --- a/apps/space/components/issues/peek-overview/issue-vote-reactions.tsx +++ b/apps/space/components/issues/peek-overview/issue-vote-reactions.tsx @@ -11,12 +11,12 @@ export const IssueVotes: React.FC = observer(() => { const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - const { user: userStore, issue: issueStore } = useMobxStore(); + const { user: userStore, issueDetails: issueDetailsStore } = useMobxStore(); const user = userStore?.currentUser; - const issueId = issueStore.activePeekOverviewIssueId; + const issueId = issueDetailsStore.peekId; - const votes = issueId ? issueStore.issue_detail[issueId]?.votes : []; + const votes = issueId ? issueDetailsStore.details[issueId]?.votes : []; const upVoteCount = votes?.filter((vote) => vote.vote === 1).length || 0; const downVoteCount = votes?.filter((vote) => vote.vote === -1).length || 0; @@ -31,9 +31,9 @@ export const IssueVotes: React.FC = observer(() => { const actionPerformed = votes?.find((vote) => vote.actor === user?.id && vote.vote === voteValue); - if (actionPerformed) await issueStore.deleteIssueVoteAsync(workspace_slug, project_slug, issueId); + if (actionPerformed) await issueDetailsStore.removeIssueVote(workspace_slug, project_slug, issueId); else - await issueStore.createIssueVoteAsync(workspace_slug, project_slug, issueId, { + await issueDetailsStore.addIssueVote(workspace_slug, project_slug, issueId, { vote: voteValue, }); @@ -43,7 +43,7 @@ export const IssueVotes: React.FC = observer(() => { useEffect(() => { if (user) return; - userStore.getUserAsync(); + userStore.fetchCurrentUser(); }, [user, userStore]); return ( @@ -57,16 +57,11 @@ export const IssueVotes: React.FC = observer(() => { handleVote(e, 1); }); }} - className={`flex items-center justify-center overflow-hidden px-2 py-1 gap-x-1 border rounded focus:outline-none ${ + className={`flex items-center justify-center overflow-hidden px-2 gap-x-1 border rounded focus:outline-none ${ isUpVotedByUser ? "border-custom-primary-200 text-custom-primary-200" : "border-custom-border-300" }`} > - - - + arrow_upward_alt {upVoteCount} @@ -79,16 +74,13 @@ export const IssueVotes: React.FC = observer(() => { handleVote(e, -1); }); }} - className={`flex items-center justify-center overflow-hidden px-2 py-1 gap-x-1 border rounded focus:outline-none ${ + className={`flex items-center justify-center overflow-hidden px-2 gap-x-1 border rounded focus:outline-none ${ isDownVotedByUser ? "border-red-600 text-red-600" : "border-custom-border-300" }`} > - - - + + arrow_upward_alt + {downVoteCount}
diff --git a/apps/space/components/issues/peek-overview/layout.tsx b/apps/space/components/issues/peek-overview/layout.tsx index c50bd7066..c6787d7ed 100644 --- a/apps/space/components/issues/peek-overview/layout.tsx +++ b/apps/space/components/issues/peek-overview/layout.tsx @@ -1,28 +1,29 @@ -import React, { useEffect, useMemo } from "react"; +import React, { useEffect, useState } from "react"; + import { useRouter } from "next/router"; -import { Dialog, Transition } from "@headlessui/react"; + +// mobx import { observer } from "mobx-react-lite"; +// headless ui +import { Dialog, Transition } from "@headlessui/react"; // components import { FullScreenPeekView, SidePeekView } from "components/issues/peek-overview"; -// types -import type { IIssue } from "types/issue"; // lib import { useMobxStore } from "lib/mobx/store-provider"; -type Props = { - isOpen: boolean; - onClose: () => void; -}; +type Props = {}; export const IssuePeekOverview: React.FC = observer((props) => { - const { isOpen, onClose } = props; + const [isSidePeekOpen, setIsSidePeekOpen] = useState(false); + const [isModalPeekOpen, setIsModalPeekOpen] = useState(false); + // router const router = useRouter(); const { workspace_slug, project_slug, peekId } = router.query; // store const { issueDetails: issueDetailStore, issue: issueStore } = useMobxStore(); - const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : null; + const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : undefined; useEffect(() => { if (workspace_slug && project_slug && peekId && issueStore.issues && issueStore.issues.length > 0) { @@ -33,15 +34,60 @@ export const IssuePeekOverview: React.FC = observer((props) => { }, [workspace_slug, project_slug, issueDetailStore, issueDetails, peekId, issueStore.issues]); const handleClose = () => { - onClose(); - issueDetailStore.setPeekMode("side"); + const { query } = router; + delete query.peekId; + + issueDetailStore.setPeekId(null); + router.replace( + { + pathname: `/${workspace_slug?.toString()}/${project_slug}`, + query, + }, + undefined, + { shallow: true } + ); }; + useEffect(() => { + if (peekId) { + if (issueDetailStore.peekMode === "side") { + setIsSidePeekOpen(true); + setIsModalPeekOpen(false); + } else { + setIsModalPeekOpen(true); + setIsSidePeekOpen(false); + } + } else { + setIsSidePeekOpen(false); + setIsModalPeekOpen(false); + } + }, [peekId, issueDetailStore.peekMode]); + return ( - - - {/* add backdrop conditionally */} - {(issueDetailStore.peekMode === "modal" || issueDetailStore.peekMode === "full") && ( + <> + + +
+
+ + + + + +
+
+
+
+ + = observer((props) => { >
- )} -
-
- - +
+ - {(issueDetailStore.peekMode === "side" || issueDetailStore.peekMode === "modal") && ( - - )} - {issueDetailStore.peekMode === "full" && ( - - )} - - + + {issueDetailStore.peekMode === "modal" && ( + + )} + {issueDetailStore.peekMode === "full" && ( + + )} + + +
-
-
-
+
+
+ ); }); diff --git a/apps/space/components/issues/peek-overview/side-peek-view.tsx b/apps/space/components/issues/peek-overview/side-peek-view.tsx index 967c2ae69..d7afc3808 100644 --- a/apps/space/components/issues/peek-overview/side-peek-view.tsx +++ b/apps/space/components/issues/peek-overview/side-peek-view.tsx @@ -1,7 +1,4 @@ -import { useEffect } from "react"; import { observer } from "mobx-react-lite"; -// lib -import { useMobxStore } from "lib/mobx/store-provider"; // components import { PeekOverviewHeader, @@ -9,26 +6,24 @@ import { PeekOverviewIssueDetails, PeekOverviewIssueProperties, } from "components/issues/peek-overview"; -// types -import { IIssue } from "types/issue"; -import { RootStore } from "store/root"; + +import { Loader } from "components/ui/loader"; +import { IIssue } from "types"; type Props = { handleClose: () => void; - issueDetails: IIssue; + issueDetails: IIssue | undefined; }; export const SidePeekView: React.FC = observer((props) => { const { handleClose, issueDetails } = props; - const { project: projectStore } = useMobxStore(); - return (
- {issueDetails && ( + {issueDetails ? (
{/* issue title and description */}
@@ -41,12 +36,19 @@ export const SidePeekView: React.FC = observer((props) => { {/* divider */}
{/* issue activity/comments */} - {projectStore?.deploySettings?.comments && ( -
- -
- )} +
+ +
+ ) : ( + + +
+ + + +
+
)}
); diff --git a/apps/space/components/ui/loader.tsx b/apps/space/components/ui/loader.tsx new file mode 100644 index 000000000..b9d13883a --- /dev/null +++ b/apps/space/components/ui/loader.tsx @@ -0,0 +1,25 @@ +import React from "react"; + +type Props = { + children: React.ReactNode; + className?: string; +}; + +const Loader = ({ children, className = "" }: Props) => ( +
+ {children} +
+); + +type ItemProps = { + height?: string; + width?: string; +}; + +const Item: React.FC = ({ height = "auto", width = "auto" }) => ( +
+); + +Loader.Item = Item; + +export { Loader }; diff --git a/apps/space/components/views/project-details.tsx b/apps/space/components/views/project-details.tsx index 8f46af79c..bbf043130 100644 --- a/apps/space/components/views/project-details.tsx +++ b/apps/space/components/views/project-details.tsx @@ -23,8 +23,6 @@ export const ProjectDetailsView = observer(() => { user: userStore, }: RootStore = useMobxStore(); - const activeIssueId = issueDetailStore.peekId; - useEffect(() => { if (!userStore.currentUser) { userStore.fetchCurrentUser(); @@ -48,23 +46,9 @@ export const ProjectDetailsView = observer(() => { } }, [peekId, issueDetailStore, project_slug, workspace_slug]); - const handlePeekClose = () => { - issueDetailStore.setPeekId(null); - router.replace( - { - pathname: `/${workspace_slug?.toString()}/${project_slug}`, - query: { - ...(board && { board: board.toString() }), - }, - }, - undefined, - { shallow: true } - ); - }; - return (
- {workspace_slug && } + {workspace_slug && } {issueStore?.loader && !issueStore.issues ? (
Loading...
@@ -79,9 +63,7 @@ export const ProjectDetailsView = observer(() => { <> {projectStore?.activeBoard === "list" && (
-
- -
+
)} {projectStore?.activeBoard === "kanban" && ( diff --git a/apps/space/constants/data.ts b/apps/space/constants/data.ts index 2de9793ef..29d411342 100644 --- a/apps/space/constants/data.ts +++ b/apps/space/constants/data.ts @@ -36,31 +36,31 @@ export const issuePriorityFilters: IIssuePriorityFilters[] = [ { key: "urgent", title: "Urgent", - className: "bg-red-500/10 text-red-500", + className: "bg-red-500 border-red-500 text-white", icon: "error", }, { key: "high", title: "High", - className: "bg-orange-500/10 text-orange-500", + className: "text-orange-500 border-custom-border-300", icon: "signal_cellular_alt", }, { key: "medium", title: "Medium", - className: "bg-yellow-500/10 text-yellow-500", + className: "text-yellow-500 border-custom-border-300", icon: "signal_cellular_alt_2_bar", }, { key: "low", title: "Low", - className: "bg-green-500/10 text-green-500", + className: "text-green-500 border-custom-border-300", icon: "signal_cellular_alt_1_bar", }, { key: "none", title: "None", - className: "bg-gray-500/10 text-gray-500", + className: "text-gray-500 border-custom-border-300", icon: "block", }, ]; diff --git a/apps/space/constants/helpers.ts b/apps/space/constants/helpers.ts index fd4dba217..0cf142353 100644 --- a/apps/space/constants/helpers.ts +++ b/apps/space/constants/helpers.ts @@ -11,3 +11,26 @@ export const renderDateFormat = (date: string | Date | null) => { return [year, month, day].join("-"); }; + +/** + * @description Returns date and month, if date is of the current year + * @description Returns date, month adn year, if date is of a different year than current + * @param {string} date + * @example renderFullDate("2023-01-01") // 1 Jan + * @example renderFullDate("2021-01-01") // 1 Jan, 2021 + */ + +export const renderFullDate = (date: string): string => { + if (!date) return ""; + + const months: string[] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + + const currentDate: Date = new Date(); + const [year, month, day]: number[] = date.split("-").map(Number); + + const formattedMonth: string = months[month - 1]; + const formattedDay: string = day < 10 ? `0${day}` : day.toString(); + + if (currentDate.getFullYear() === year) return `${formattedDay} ${formattedMonth}`; + else return `${formattedDay} ${formattedMonth}, ${year}`; +}; diff --git a/apps/space/layouts/project-layout.tsx b/apps/space/layouts/project-layout.tsx index 7d5990666..2147e845e 100644 --- a/apps/space/layouts/project-layout.tsx +++ b/apps/space/layouts/project-layout.tsx @@ -1,16 +1,18 @@ import Link from "next/link"; import Image from "next/image"; + +// mobx import { observer } from "mobx-react-lite"; // components import IssueNavbar from "components/issues/navbar"; const ProjectLayout = ({ children }: { children: React.ReactNode }) => (
-
+
-
{children}
-
+
{children}
+
diff --git a/apps/space/pages/_app.tsx b/apps/space/pages/_app.tsx index 8eb5868cd..3bfdf5930 100644 --- a/apps/space/pages/_app.tsx +++ b/apps/space/pages/_app.tsx @@ -3,6 +3,7 @@ import type { AppProps } from "next/app"; import { ThemeProvider } from "next-themes"; // styles import "styles/globals.css"; +import "styles/editor.css"; // contexts import { ToastContextProvider } from "contexts/toast.context"; // mobx store provider diff --git a/apps/space/store/issue.ts b/apps/space/store/issue.ts index c14175beb..d47336984 100644 --- a/apps/space/store/issue.ts +++ b/apps/space/store/issue.ts @@ -22,6 +22,7 @@ export interface IIssueStore { issueService: any; // actions fetchPublicIssues: (workspace_slug: string, project_slug: string, params: any) => void; + getCountOfIssuesByState: (state: string) => number; getFilteredIssuesByState: (state: string) => IIssue[]; } @@ -90,6 +91,11 @@ class IssueStore implements IIssueStore { } }; + // computed + getCountOfIssuesByState(state_id: string): number { + return this.issues?.filter((issue) => issue.state == state_id).length || 0; + } + getFilteredIssuesByState = (state_id: string): IIssue[] | [] => this.issues?.filter((issue) => issue.state == state_id) || []; } diff --git a/apps/space/store/issue_details.ts b/apps/space/store/issue_details.ts index 89b2c2c8f..8185ce2f2 100644 --- a/apps/space/store/issue_details.ts +++ b/apps/space/store/issue_details.ts @@ -3,6 +3,7 @@ import { makeObservable, observable, action, runInAction } from "mobx"; import { RootStore } from "./root"; // services import IssueService from "services/issue.service"; +import { IIssue } from "types"; export type IPeekMode = "side" | "modal" | "full"; @@ -12,21 +13,23 @@ export interface IIssueDetailStore { // peek info peekId: string | null; peekMode: IPeekMode; - details: any; + details: { + [key: string]: IIssue; + }; // peek actions setPeekId: (issueId: string | null) => void; setPeekMode: (mode: IPeekMode) => void; // issue details fetchIssueDetails: (workspaceId: string, projectId: string, issueId: string) => void; // issue comments - addIssueComment: (workspaceId: string, projectId: string, issueId: string, data: any) => void; + addIssueComment: (workspaceId: string, projectId: string, issueId: string, data: any) => Promise; deleteIssueComment: (workspaceId: string, projectId: string, issueId: string) => void; // issue reactions - addIssueReaction: (workspaceId: string, projectId: string, issueId: string) => void; - removeIssueReaction: (workspaceId: string, projectId: string, issueId: string) => void; + addIssueReaction: (workspaceId: string, projectId: string, issueId: string, data: any) => void; + removeIssueReaction: (workspaceId: string, projectId: string, issueId: string, data: any) => void; // issue votes - addIssueVote: (workspaceId: string, projectId: string, issueId: string) => void; - removeIssueVote: (workspaceId: string, projectId: string, issueId: string) => void; + addIssueVote: (workspaceId: string, projectId: string, issueId: string, data: { vote: 1 | -1 }) => Promise; + removeIssueVote: (workspaceId: string, projectId: string, issueId: string) => Promise; } class IssueDetailStore implements IssueDetailStore { @@ -34,7 +37,9 @@ class IssueDetailStore implements IssueDetailStore { error: any = null; peekId: string | null = null; peekMode: IPeekMode = "side"; - details: any = {}; + details: { + [key: string]: IIssue; + } = {}; issueService: any; rootStore: RootStore; @@ -150,10 +155,10 @@ class IssueDetailStore implements IssueDetailStore { } }; - addIssueReaction = async () => { + addIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, data: any) => { try { const issueVoteResponse = await this.issueService.createIssueReaction(workspaceSlug, projectId, issueId, data); - // const issueDetails = await this.issueService.fetchIssueDetails(workspaceSlug, projectId, issueId); + const issueDetails = await this.issueService.getIssueById(workspaceSlug, projectId, issueId); if (issueVoteResponse) { runInAction(() => { @@ -170,12 +175,12 @@ class IssueDetailStore implements IssueDetailStore { } }; - removeIssueReaction = async () => { + removeIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, data: any) => { try { const issueVoteResponse = await this.issueService.deleteIssueReaction(workspaceSlug, projectId, issueId, data); - // const issueDetails = await this.issueService.fetchIssueDetails(workspaceSlug, projectId, issueId); + const issueDetails = await this.issueService.getIssueById(workspaceSlug, projectId, issueId); - if (issueVoteResponse) { + if (issueVoteResponse && issueDetails) { runInAction(() => { this.details = { ...this.details, @@ -193,7 +198,7 @@ class IssueDetailStore implements IssueDetailStore { addIssueVote = async (workspaceSlug: string, projectId: string, issueId: string, data: { vote: 1 | -1 }) => { try { const issueVoteResponse = await this.issueService.createIssueVote(workspaceSlug, projectId, issueId, data); - // const issueDetails = await this.issueService.fetchIssueDetails(workspaceSlug, projectId, issueId); + const issueDetails = await this.issueService.getIssueById(workspaceSlug, projectId, issueId); if (issueVoteResponse) { runInAction(() => { @@ -212,8 +217,8 @@ class IssueDetailStore implements IssueDetailStore { removeIssueVote = async (workspaceSlug: string, projectId: string, issueId: string) => { try { - const issueVoteResponse = await this.issueService.deleteIssueVote(workspaceSlug, projectId, issueId, data); - // const issueDetails = await this.issueService.fetchIssueDetails(workspaceSlug, projectId, issueId); + const issueVoteResponse = await this.issueService.deleteIssueVote(workspaceSlug, projectId, issueId); + const issueDetails = await this.issueService.getIssueById(workspaceSlug, projectId, issueId); if (issueVoteResponse) { runInAction(() => { diff --git a/apps/space/store/user.ts b/apps/space/store/user.ts index 9cd25bf8c..4ccc2d2bb 100644 --- a/apps/space/store/user.ts +++ b/apps/space/store/user.ts @@ -18,7 +18,7 @@ class UserStore implements IUserStore { constructor(_rootStore: any) { makeObservable(this, { // observable - currentUser: observable, + currentUser: observable.ref, // actions setCurrentUser: action, // computed diff --git a/apps/space/styles/editor.css b/apps/space/styles/editor.css new file mode 100644 index 000000000..9da250dd1 --- /dev/null +++ b/apps/space/styles/editor.css @@ -0,0 +1,231 @@ +.ProseMirror p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: rgb(var(--color-text-400)); + pointer-events: none; + height: 0; +} + +.ProseMirror .is-empty::before { + content: attr(data-placeholder); + float: left; + color: rgb(var(--color-text-400)); + pointer-events: none; + height: 0; +} + +/* Custom image styles */ + +.ProseMirror img { + transition: filter 0.1s ease-in-out; + + &:hover { + cursor: pointer; + filter: brightness(90%); + } + + &.ProseMirror-selectednode { + outline: 3px solid #5abbf7; + filter: brightness(90%); + } +} + +.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 { + margin-right: 0.2rem; + user-select: none; +} + +@media screen and (max-width: 768px) { + ul[data-type="taskList"] li > label { + margin-right: 0.5rem; + } +} + +ul[data-type="taskList"] li > label input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + background-color: rgb(var(--color-background-100)); + margin: 0; + cursor: pointer; + width: 1.2rem; + height: 1.2rem; + position: relative; + border: 2px solid rgb(var(--color-text-100)); + margin-right: 0.3rem; + display: grid; + place-content: center; + + &:hover { + background-color: rgb(var(--color-background-80)); + } + + &:active { + background-color: rgb(var(--color-background-90)); + } + + &::before { + content: ""; + width: 0.65em; + height: 0.65em; + transform: scale(0); + transition: 120ms transform ease-in-out; + box-shadow: inset 1em 1em; + transform-origin: center; + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); + } + + &:checked::before { + transform: scale(1); + } +} + +ul[data-type="taskList"] li[data-checked="true"] > div > p { + color: rgb(var(--color-text-200)); + text-decoration: line-through; + text-decoration-thickness: 2px; +} + +/* Overwrite tippy-box original max-width */ + +.tippy-box { + max-width: 400px !important; +} + +.ProseMirror { + position: relative; + word-wrap: break-word; + white-space: pre-wrap; + -moz-tab-size: 4; + tab-size: 4; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + outline: none; + cursor: text; + line-height: 1.2; + font-family: inherit; + font-size: 14px; + color: inherit; + -moz-box-sizing: border-box; + box-sizing: border-box; + appearance: textfield; + -webkit-appearance: textfield; + -moz-appearance: textfield; +} + +.fadeIn { + opacity: 1; + transition: opacity 0.3s ease-in; +} + +.fadeOut { + opacity: 0; + transition: opacity 0.2s ease-out; +} + +.img-placeholder { + position: relative; + width: 35%; + + &:before { + content: ""; + box-sizing: border-box; + position: absolute; + top: 50%; + left: 45%; + width: 20px; + height: 20px; + border-radius: 50%; + border: 3px solid rgba(var(--color-text-200)); + border-top-color: rgba(var(--color-text-800)); + animation: spinning 0.6s linear infinite; + } +} + +@keyframes spinning { + to { + transform: rotate(360deg); + } +} + +#tiptap-container { + table { + border-collapse: collapse; + table-layout: fixed; + margin: 0; + border: 1px solid rgb(var(--color-border-200)); + width: 100%; + + td, + th { + min-width: 1em; + border: 1px solid rgb(var(--color-border-200)); + padding: 10px 15px; + vertical-align: top; + box-sizing: border-box; + position: relative; + transition: background-color 0.3s ease; + + > * { + margin-bottom: 0; + } + } + + th { + font-weight: bold; + text-align: left; + background-color: rgb(var(--color-primary-100)); + } + + td:hover { + background-color: rgba(var(--color-primary-300), 0.1); + } + + .selectedCell:after { + z-index: 2; + position: absolute; + content: ""; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: rgba(var(--color-primary-300), 0.1); + pointer-events: none; + } + + .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: -2px; + width: 2px; + background-color: rgb(var(--color-primary-400)); + pointer-events: none; + } + } +} + +.tableWrapper { + overflow-x: auto; +} + +.resize-cursor { + cursor: ew-resize; + cursor: col-resize; +} + +.ProseMirror table * p { + padding: 0px 1px; + margin: 6px 2px; +} + +.ProseMirror table * .is-empty::before { + opacity: 0; +} diff --git a/apps/space/styles/globals.css b/apps/space/styles/globals.css index bf0609057..1782b9b81 100644 --- a/apps/space/styles/globals.css +++ b/apps/space/styles/globals.css @@ -46,6 +46,25 @@ --color-border-300: 212, 212, 212; /* strong border- 1 */ --color-border-400: 185, 185, 185; /* strong border- 2 */ + --color-shadow-2xs: 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06), + 0px 1px 2px 0px rgba(23, 23, 23, 0.14); + --color-shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12), + 0px 1px 8px -1px rgba(16, 24, 40, 0.1); + --color-shadow-sm: 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); + --color-shadow-rg: 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08), + 0px 1px 12px 0px rgba(16, 24, 40, 0.04); + --color-shadow-md: 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), + 0px 1px 16px 0px rgba(16, 24, 40, 0.12); + --color-shadow-lg: 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12), + 0px 1px 24px 0px rgba(16, 24, 40, 0.12); + --color-shadow-xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16), + 0px 0px 52px 0px rgba(16, 24, 40, 0.16); + --color-shadow-2xl: 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12), + 0px 1px 32px 0px rgba(16, 24, 40, 0.12); + --color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12), + 0px 1px 48px 0px rgba(16, 24, 40, 0.12); + --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */ --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */ --color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */ @@ -59,9 +78,20 @@ --color-sidebar-border-200: var(--color-border-100); /* subtle sidebar border- 2 */ --color-sidebar-border-300: var(--color-border-100); /* strong sidebar border- 1 */ --color-sidebar-border-400: var(--color-border-100); /* strong sidebar border- 2 */ + + --color-sidebar-shadow-2xs: var(--color-shadow-2xs); + --color-sidebar-shadow-xs: var(--color-shadow-xs); + --color-sidebar-shadow-sm: var(--color-shadow-sm); + --color-sidebar-shadow-rg: var(--color-shadow-rg); + --color-sidebar-shadow-md: var(--color-shadow-md); + --color-sidebar-shadow-lg: var(--color-shadow-lg); + --color-sidebar-shadow-xl: var(--color-shadow-xl); + --color-sidebar-shadow-2xl: var(--color-shadow-2xl); + --color-sidebar-shadow-3xl: var(--color-shadow-3xl); } - [data-theme="light"] { + [data-theme="light"], + [data-theme="light-contrast"] { color-scheme: light !important; --color-background-100: 255, 255, 255; /* primary bg */ @@ -93,12 +123,23 @@ --color-border-400: 58, 58, 58; /* strong border- 2 */ } - [data-theme="dark"] { + [data-theme="dark"], + [data-theme="dark-contrast"] { color-scheme: dark !important; --color-background-100: 7, 7, 7; /* primary bg */ --color-background-90: 11, 11, 11; /* secondary bg */ --color-background-80: 23, 23, 23; /* tertiary bg */ + + --color-shadow-2xs: 0px 0px 1px 0px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.5); + --color-shadow-xs: 0px 0px 2px 0px rgba(0, 0, 0, 0.2), 0px 2px 4px 0px rgba(0, 0, 0, 0.5); + --color-shadow-sm: 0px 0px 4px 0px rgba(0, 0, 0, 0.2), 0px 2px 6px 0px rgba(0, 0, 0, 0.5); + --color-shadow-rg: 0px 0px 6px 0px rgba(0, 0, 0, 0.2), 0px 4px 6px 0px rgba(0, 0, 0, 0.5); + --color-shadow-md: 0px 2px 8px 0px rgba(0, 0, 0, 0.2), 0px 4px 8px 0px rgba(0, 0, 0, 0.5); + --color-shadow-lg: 0px 4px 12px 0px rgba(0, 0, 0, 0.25), 0px 4px 10px 0px rgba(0, 0, 0, 0.55); + --color-shadow-xl: 0px 0px 14px 0px rgba(0, 0, 0, 0.25), 0px 6px 10px 0px rgba(0, 0, 0, 0.55); + --color-shadow-2xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.25), 0px 8px 12px 0px rgba(0, 0, 0, 0.6); + --color-shadow-3xl: 0px 4px 24px 0px rgba(0, 0, 0, 0.3), 0px 12px 40px 0px rgba(0, 0, 0, 0.65); } [data-theme="dark"] { @@ -113,8 +154,22 @@ --color-border-400: 58, 58, 58; /* strong border- 2 */ } + [data-theme="dark-contrast"] { + --color-text-100: 250, 250, 250; /* primary text */ + --color-text-200: 241, 241, 241; /* secondary text */ + --color-text-300: 212, 212, 212; /* tertiary text */ + --color-text-400: 115, 115, 115; /* placeholder text */ + + --color-border-100: 245, 245, 245; /* subtle border= 1 */ + --color-border-200: 229, 229, 229; /* subtle border- 2 */ + --color-border-300: 212, 212, 212; /* strong border- 1 */ + --color-border-400: 185, 185, 185; /* strong border- 2 */ + } + [data-theme="light"], - [data-theme="dark"] { + [data-theme="dark"], + [data-theme="light-contrast"], + [data-theme="dark-contrast"] { --color-primary-10: 236, 241, 255; --color-primary-20: 217, 228, 255; --color-primary-30: 197, 214, 255; @@ -149,3 +204,31 @@ --color-sidebar-border-400: var(--color-border-100); /* strong sidebar border- 2 */ } } + +::-webkit-scrollbar { + width: 5px; + height: 5px; + border-radius: 5px; +} + +::-webkit-scrollbar-track { + background-color: rgba(var(--color-background-100)); +} + +::-webkit-scrollbar-thumb { + border-radius: 5px; + background-color: rgba(var(--color-background-80)); +} + +.hide-vertical-scrollbar::-webkit-scrollbar { + width: 0 !important; +} + +.hide-horizontal-scrollbar::-webkit-scrollbar { + height: 0 !important; +} + +.hide-both-scrollbars::-webkit-scrollbar { + height: 0 !important; + width: 0 !important; +} diff --git a/apps/space/tailwind.config.js b/apps/space/tailwind.config.js index 7a75028cc..0347ad9f9 100644 --- a/apps/space/tailwind.config.js +++ b/apps/space/tailwind.config.js @@ -12,6 +12,26 @@ module.exports = { ], theme: { extend: { + boxShadow: { + "custom-shadow-2xs": "var(--color-shadow-2xs)", + "custom-shadow-xs": "var(--color-shadow-xs)", + "custom-shadow-sm": "var(--color-shadow-sm)", + "custom-shadow-rg": "var(--color-shadow-rg)", + "custom-shadow-md": "var(--color-shadow-md)", + "custom-shadow-lg": "var(--color-shadow-lg)", + "custom-shadow-xl": "var(--color-shadow-xl)", + "custom-shadow-2xl": "var(--color-shadow-2xl)", + "custom-shadow-3xl": "var(--color-shadow-3xl)", + "custom-sidebar-shadow-2xs": "var(--color-sidebar-shadow-2xs)", + "custom-sidebar-shadow-xs": "var(--color-sidebar-shadow-xs)", + "custom-sidebar-shadow-sm": "var(--color-sidebar-shadow-sm)", + "custom-sidebar-shadow-rg": "var(--color-sidebar-shadow-rg)", + "custom-sidebar-shadow-md": "var(--color-sidebar-shadow-md)", + "custom-sidebar-shadow-lg": "var(--color-sidebar-shadow-lg)", + "custom-sidebar-shadow-xl": "var(--color-sidebar-shadow-xl)", + "custom-sidebar-shadow-2xl": "var(--color-sidebar-shadow-2xl)", + "custom-sidebar-shadow-3xl": "var(--color-sidebar-shadow-3xl)", + }, colors: { custom: { primary: { @@ -179,7 +199,5 @@ module.exports = { custom: ["Inter", "sans-serif"], }, }, - plugins: [ - require("@tailwindcss/typography"), - ], + plugins: [require("@tailwindcss/typography")], }; diff --git a/apps/space/types/issue.ts b/apps/space/types/issue.ts index bf05ef797..2d9ac0b7f 100644 --- a/apps/space/types/issue.ts +++ b/apps/space/types/issue.ts @@ -29,17 +29,26 @@ export interface IIssueGroup { export interface IIssue { id: string; - sequence_id: number; - name: string; + comments: Comment[]; description_html: string; + label_details: any; + name: string; + priority: TIssuePriorityKey | null; project: string; project_detail: any; - priority: TIssuePriorityKey | null; + reactions: IIssueReaction[]; + sequence_id: number; + start_date: any; state: string; state_detail: any; - label_details: any; target_date: any; - start_date: any; + votes: { + issue: string; + vote: -1 | 1; + workspace: string; + project: string; + actor: string; + }[]; } export interface IIssueState { @@ -66,7 +75,6 @@ export interface Comment { created_at: Date; updated_at: Date; comment_stripped: string; - comment_json: any; comment_html: string; attachments: any[]; access: string; @@ -78,6 +86,13 @@ export interface Comment { actor: string; } +export interface IIssueReaction { + actor_detail: ActorDetail; + id: string; + issue: string; + reaction: string; +} + export interface ActorDetail { id: string; first_name: string; diff --git a/yarn.lock b/yarn.lock index 9c8bcdf65..793a624ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2253,10 +2253,10 @@ 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.1.7": - version "2.1.7" - resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.1.7.tgz#1cd783adfe2788d41614f8851b8d7a52ec027cce" - integrity sha512-6gFXXlCGAdXjy27BW29q4yfCQPAEFd18k7zRTnbd4aE/zIWUtLqdiTfI3kotUMab9Tt9/z1BRmCbEUxRsf1Nww== +"@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" + integrity sha512-4j8BZa6diuoRytWoIc7j25EYWWut5TZDLbb+OVURdkHnsF8B8zeNTo55W40CdwSaSyTtXtxbTIldV80ShQarGQ== "@tiptap/extension-heading@^2.1.7": version "2.1.7" @@ -2320,6 +2320,26 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.1.7.tgz#b7b7f49254f1de22416b1415ca88a2a20edd0627" integrity sha512-ONLXYnuZGM2EoGcxkyvJSDMBeAp7K6l83UXkK9TSj+VpEEDdeV7m8mJs8/vACJjJxD5HMN61+EPgU7VTEukQCA== +"@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.1.7" resolved "https://registry.yarnpkg.com/@tiptap/extension-task-item/-/extension-task-item-2.1.7.tgz#384a55308f3524f36388560486a2508a4b3c5413"