diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index f628c0358..2dafaa7e6 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -308,5 +308,5 @@ class IssueActivitySerializer(BaseSerializer): model = IssueActivity exclude = [ "created_by", - "udpated_by", + "updated_by", ] diff --git a/apiserver/plane/api/urls/cycle.py b/apiserver/plane/api/urls/cycle.py index b859c28b2..f557f8af0 100644 --- a/apiserver/plane/api/urls/cycle.py +++ b/apiserver/plane/api/urls/cycle.py @@ -13,7 +13,7 @@ urlpatterns = [ name="cycles", ), path( - "workspaces//projects//cycles//", + "workspaces//projects//cycles//", CycleAPIEndpoint.as_view(), name="cycles", ), @@ -23,7 +23,7 @@ urlpatterns = [ name="cycle-issues", ), path( - "workspaces//projects//cycles//cycle-issues//", + "workspaces//projects//cycles//cycle-issues//", CycleIssueAPIEndpoint.as_view(), name="cycle-issues", ), diff --git a/apiserver/plane/api/urls/issue.py b/apiserver/plane/api/urls/issue.py index e6770579d..070ea8bd9 100644 --- a/apiserver/plane/api/urls/issue.py +++ b/apiserver/plane/api/urls/issue.py @@ -20,22 +20,22 @@ urlpatterns = [ name="issue", ), path( - "workspaces//projects//issue-labels/", + "workspaces//projects//labels/", LabelAPIEndpoint.as_view(), name="label", ), path( - "workspaces//projects//issue-labels//", + "workspaces//projects//labels//", LabelAPIEndpoint.as_view(), name="label", ), path( - "workspaces//projects//issues//issue-links/", + "workspaces//projects//issues//links/", IssueLinkAPIEndpoint.as_view(), name="link", ), path( - "workspaces//projects//issues//issue-links//", + "workspaces//projects//issues//links//", IssueLinkAPIEndpoint.as_view(), name="link", ), @@ -50,12 +50,12 @@ urlpatterns = [ name="comment", ), path( - "workspaces//projects//issues//activites/", + "workspaces//projects//issues//activities/", IssueActivityAPIEndpoint.as_view(), name="activity", ), path( - "workspaces//projects//issues//activites//", + "workspaces//projects//issues//activities//", IssueActivityAPIEndpoint.as_view(), name="activity", ), diff --git a/apiserver/plane/api/urls/module.py b/apiserver/plane/api/urls/module.py index 7860a0fce..7117a9e8b 100644 --- a/apiserver/plane/api/urls/module.py +++ b/apiserver/plane/api/urls/module.py @@ -19,7 +19,7 @@ urlpatterns = [ name="module-issues", ), path( - "workspaces//projects//modules//module-issues//", + "workspaces//projects//modules//module-issues//", ModuleIssueAPIEndpoint.as_view(), name="module-issues", ), diff --git a/apiserver/plane/api/urls/state.py b/apiserver/plane/api/urls/state.py index cf5eefd53..0676ac5ad 100644 --- a/apiserver/plane/api/urls/state.py +++ b/apiserver/plane/api/urls/state.py @@ -8,4 +8,9 @@ urlpatterns = [ StateAPIEndpoint.as_view(), name="states", ), + path( + "workspaces//projects//states//", + StateAPIEndpoint.as_view(), + name="states", + ), ] \ No newline at end of file diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 6cd8b2356..60bcd6fc6 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -7,7 +7,6 @@ from django.conf import settings from django.db import IntegrityError from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils import timezone -from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework.views import APIView @@ -36,20 +35,43 @@ class TimezoneMixin: else: timezone.deactivate() + class WebhookMixin: webhook_event = None def finalize_response(self, request, response, *args, **kwargs): response = super().finalize_response(request, response, *args, **kwargs) + # Check for the case should webhook be sent if ( self.webhook_event - and self.request.method in ["POST", "PATCH", "DELETE"] + and self.request.method in ["POST", "PATCH"] and response.status_code in [200, 201, 204] ): + # Get the id + object_id = ( + response.data.get("id") if isinstance(response.data, dict) else None + ) + # Push the object to delay send_webhook.delay( event=self.webhook_event, - event_data=json.dumps(response.data, cls=DjangoJSONEncoder), + event_id=object_id, + action=self.request.method, + slug=self.workspace_slug, + ) + + # Check for the case should webhook be sent + if ( + self.webhook_event + and self.request.method in ["DELETE"] + and response.status_code in [204] + ): + # Get the id + object_id = self.kwargs.get("pk") + # Push the object to delay + send_webhook.delay( + event=self.webhook_event, + event_id=object_id, action=self.request.method, slug=self.workspace_slug, ) @@ -57,7 +79,6 @@ class WebhookMixin: return response - class BaseAPIView(TimezoneMixin, APIView, BasePaginator): authentication_classes = [ APIKeyAuthentication, @@ -139,13 +160,13 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): response = super().finalize_response(request, response, *args, **kwargs) # Add custom headers if they exist in the request META - ratelimit_remaining = request.META.get('X-RateLimit-Remaining') + ratelimit_remaining = request.META.get("X-RateLimit-Remaining") if ratelimit_remaining is not None: - response['X-RateLimit-Remaining'] = ratelimit_remaining + response["X-RateLimit-Remaining"] = ratelimit_remaining - ratelimit_reset = request.META.get('X-RateLimit-Reset') + ratelimit_reset = request.META.get("X-RateLimit-Reset") if ratelimit_reset is not None: - response['X-RateLimit-Reset'] = ratelimit_reset + response["X-RateLimit-Reset"] = ratelimit_reset return response @@ -169,4 +190,4 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): expand = [ expand for expand in self.request.GET.get("expand", "").split(",") if expand ] - return expand if expand else None \ No newline at end of file + return expand if expand else None diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 3e322e489..8345d7824 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -141,7 +141,6 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): ) queryset = self.get_queryset() cycle_view = request.GET.get("cycle_view", "all") - queryset = queryset.order_by("-is_favorite", "-created_at") # Current Cycle if cycle_view == "current": @@ -292,7 +291,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): } ), actor_id=str(request.user.id), - issue_id=str(pk), + issue_id=None, project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), @@ -304,8 +303,8 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions related to cycle issues. + This viewset automatically provides `list`, `create`, + and `destroy` actions related to cycle issues. """ @@ -456,7 +455,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): # Capture Issue Activity issue_activity.delay( type="cycle.activity.created", - requested_data=json.dumps({"cycles_list": issues}), + requested_data=json.dumps({"cycles_list": str(issues)}), actor_id=str(self.request.user.id), issue_id=None, project_id=str(self.kwargs.get("project_id", None)), @@ -477,9 +476,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_200_OK, ) - def delete(self, request, slug, project_id, cycle_id, pk): + def delete(self, request, slug, project_id, cycle_id, issue_id): cycle_issue = CycleIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id + issue_id=issue_id, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id ) issue_id = cycle_issue.issue_id cycle_issue.delete() @@ -492,7 +491,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): } ), actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("pk", None)), + issue_id=str(issue_id), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, epoch=int(timezone.now().timestamp()), diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 062649d4f..41745010f 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -40,7 +40,6 @@ from plane.db.models import ( IssueComment, IssueActivity, ) -from plane.utils.issue_filters import issue_filters from plane.bgtasks.issue_activites_task import issue_activity from plane.api.serializers import ( IssueSerializer, @@ -246,6 +245,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder ) + issue.delete() issue_activity.delay( type="issue.activity.deleted", requested_data=json.dumps({"issue_id": str(pk)}), @@ -255,7 +255,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) - issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -274,7 +273,7 @@ class LabelAPIEndpoint(BaseAPIView): def get_queryset(self): return ( - Project.objects.filter(workspace__slug=self.kwargs.get("slug")) + Label.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(project__project_projectmember__member=self.request.user) .select_related("project") @@ -298,29 +297,29 @@ class LabelAPIEndpoint(BaseAPIView): ) def get(self, request, slug, project_id, pk=None): - if pk: - label = self.get_queryset().get(pk=pk) - serializer = LabelSerializer( - label, - fields=self.fields, - expand=self.expand, + if pk is None: + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda labels: LabelSerializer( + labels, + many=True, + fields=self.fields, + expand=self.expand, + ).data, ) - return Response(serializer.data, status=status.HTTP_200_OK) - return self.paginate( - request=request, - queryset=(self.get_queryset()), - on_results=lambda labels: LabelSerializer( - labels, - many=True, - fields=self.fields, - expand=self.expand, - ).data, - ) + label = self.get_queryset().get(pk=pk) + serializer = LabelSerializer(label, fields=self.fields, expand=self.expand,) + return Response(serializer.data, status=status.HTTP_200_OK) def patch(self, request, slug, project_id, pk=None): label = self.get_queryset().get(pk=pk) serializer = LabelSerializer(label, data=request.data, partial=True) - return Response(serializer.data, status=status.HTTP_200_OK) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def delete(self, request, slug, project_id, pk=None): label = self.get_queryset().get(pk=pk) @@ -352,25 +351,31 @@ class IssueLinkAPIEndpoint(BaseAPIView): .distinct() ) - def get(self, request, slug, project_id, pk=None): - if pk: - label = self.get_queryset().get(pk=pk) + def get(self, request, slug, project_id, issue_id, pk=None): + if pk is None: + issue_links = self.get_queryset() serializer = IssueLinkSerializer( - label, + issue_links, fields=self.fields, expand=self.expand, ) - return Response(serializer.data, status=status.HTTP_200_OK) - return self.paginate( - request=request, - queryset=(self.get_queryset()), - on_results=lambda issue_links: IssueLinkSerializer( - issue_links, - many=True, - fields=self.fields, - expand=self.expand, - ).data, + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda issue_links: IssueLinkSerializer( + issue_links, + many=True, + fields=self.fields, + expand=self.expand, + ).data, + ) + issue_link = self.get_queryset().get(pk=pk) + serializer = IssueLinkSerializer( + issue_link, + fields=self.fields, + expand=self.expand, ) + return Response(serializer.data, status=status.HTTP_200_OK) def post(self, request, slug, project_id, issue_id): serializer = IssueLinkSerializer(data=request.data) @@ -445,7 +450,7 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): serializer_class = IssueCommentSerializer model = IssueComment - webhook_event = "issue-comment" + webhook_event = "issue_comment" permission_classes = [ ProjectLitePermission, ] @@ -583,7 +588,7 @@ class IssueActivityAPIEndpoint(BaseAPIView): serializer = IssueActivitySerializer(issue_activities) return Response(serializer.data, status=status.HTTP_200_OK) - self.paginate( + return self.paginate( request=request, queryset=(issue_activities), on_results=lambda issue_activity: IssueActivitySerializer( diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 78f721adc..51be6be30 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -129,6 +129,14 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): serializer = ModuleSerializer(module) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, slug, project_id, pk): + module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) + serializer = ModuleSerializer(module, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def get(self, request, slug, project_id, pk=None): if pk: @@ -168,7 +176,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): } ), actor_id=str(request.user.id), - issue_id=str(pk), + issue_id=None, project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), @@ -323,7 +331,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): # Capture Issue Activity issue_activity.delay( type="module.activity.created", - requested_data=json.dumps({"modules_list": issues}), + requested_data=json.dumps({"modules_list": str(issues)}), actor_id=str(self.request.user.id), issue_id=None, project_id=str(self.kwargs.get("project_id", None)), @@ -343,9 +351,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_200_OK, ) - def delete(self, request, slug, project_id, module_id, pk): + def delete(self, request, slug, project_id, module_id, issue_id): module_issue = ModuleIssue.objects.get( - workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk + workspace__slug=slug, project_id=project_id, module_id=module_id, issue_id=issue_id ) module_issue.delete() issue_activity.delay( @@ -357,7 +365,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): } ), actor_id=str(request.user.id), - issue_id=str(pk), + issue_id=str(issue_id), project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 378a31341..812a0072e 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -281,4 +281,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): return Response( {"identifier": "The project identifier is already taken"}, status=status.HTTP_410_GONE, - ) \ No newline at end of file + ) + + def delete(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project.delete() + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 8e7a73d9b..99b3a943f 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -23,10 +23,8 @@ class StateAPIEndpoint(BaseAPIView): ] def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) + return ( + State.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(project__project_projectmember__member=self.request.user) .filter(~Q(name="Triage")) @@ -42,9 +40,9 @@ class StateAPIEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def get(self, request, slug, project_id, pk=None): - if pk: - serializer = StateSerializer(self.get_queryset().get(pk=pk)) + def get(self, request, slug, project_id, state_id=None): + if state_id: + serializer = StateSerializer(self.get_queryset().get(pk=state_id)) return Response(serializer.data, status=status.HTTP_200_OK) return self.paginate( request=request, @@ -57,10 +55,10 @@ class StateAPIEndpoint(BaseAPIView): ).data, ) - def delete(self, request, slug, project_id, pk): + def delete(self, request, slug, project_id, state_id): state = State.objects.get( ~Q(name="Triage"), - pk=pk, + pk=state_id, project_id=project_id, workspace__slug=slug, ) @@ -69,7 +67,7 @@ class StateAPIEndpoint(BaseAPIView): return Response({"error": "Default state cannot be deleted"}, status=False) # Check for any issues in the state - issue_exist = Issue.issue_objects.filter(state=pk).exists() + issue_exist = Issue.issue_objects.filter(state=state_id).exists() if issue_exist: return Response( @@ -80,8 +78,8 @@ class StateAPIEndpoint(BaseAPIView): state.delete() return Response(status=status.HTTP_204_NO_CONTENT) - def patch(self, request, slug, project_id, pk=None): - state = State.objects.filter(workspace__slug=slug, project_id=project_id, pk=pk) + def patch(self, request, slug, project_id, state_id=None): + state = State.objects.filter(workspace__slug=slug, project_id=project_id, pk=state_id) serializer = StateSerializer(state, data=request.data, partial=True) if serializer.is_valid(): serializer.save() diff --git a/apiserver/plane/app/serializers/webhook.py b/apiserver/plane/app/serializers/webhook.py index 351b6fe7d..d5b3eeddd 100644 --- a/apiserver/plane/app/serializers/webhook.py +++ b/apiserver/plane/app/serializers/webhook.py @@ -1,3 +1,9 @@ +# Python imports +import urllib +import socket +import ipaddress +from urllib.parse import urlparse + # Third party imports from rest_framework import serializers @@ -9,6 +15,42 @@ from plane.db.models.webhook import validate_domain, validate_schema class WebhookSerializer(DynamicBaseSerializer): url = serializers.URLField(validators=[validate_schema, validate_domain]) + def validate(self, data): + url = data.get("url", None) + + # Extract the hostname from the URL + hostname = urlparse(url).hostname + if not hostname: + raise serializers.ValidationError({"url": "Invalid URL: No hostname found."}) + + # Resolve the hostname to IP addresses + try: + ip_addresses = socket.getaddrinfo(hostname, None) + except socket.gaierror: + raise serializers.ValidationError({"url": "Hostname could not be resolved."}) + + if not ip_addresses: + raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) + + for addr in ip_addresses: + ip = ipaddress.ip_address(addr[4][0]) + if ip.is_private or ip.is_loopback: + raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."}) + + # Additional validation for multiple request domains and their subdomains + request = self.context.get('request') + disallowed_domains = ['plane.so',] # Add your disallowed domains here + if request: + request_host = request.get_host().split(':')[0] # Remove port if present + disallowed_domains.append(request_host) + + # Check if hostname is a subdomain or exact match of any disallowed domain + if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains): + raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."}) + + return data + + class Meta: model = Webhook fields = "__all__" diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index de7bafd57..ba18dbbfa 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -46,22 +46,26 @@ class WebhookMixin: def finalize_response(self, request, response, *args, **kwargs): response = super().finalize_response(request, response, *args, **kwargs) - if ( self.webhook_event - and self.request.method in ["POST", "PATCH", "DELETE"] + and self.request.method in ["POST", "PATCH"] and response.status_code in [200, 201, 204] ): + # Get the id + object_id = ( + response.data.get("id") if isinstance(response.data, dict) else None + ) + send_webhook.delay( event=self.webhook_event, - event_data=json.dumps(response.data, cls=DjangoJSONEncoder), + event_id=object_id, action=self.request.method, slug=self.workspace_slug, ) - return response + class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): model = None diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 7228aa088..5a2cf6807 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -688,7 +688,6 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id ) issue_id = cycle_issue.issue_id - cycle_issue.delete() issue_activity.delay( type="cycle.activity.deleted", requested_data=json.dumps( @@ -698,11 +697,12 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): } ), actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("pk", None)), + issue_id=str(cycle_issue.issue_id), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, epoch=int(timezone.now().timestamp()), ) + cycle_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/external.py b/apiserver/plane/app/views/external.py index ac502c186..34f21d00e 100644 --- a/apiserver/plane/app/views/external.py +++ b/apiserver/plane/app/views/external.py @@ -1,6 +1,6 @@ # Python imports import requests - +import os # Third party imports from openai import OpenAI from rest_framework.response import Response @@ -85,14 +85,22 @@ class ReleaseNotesEndpoint(BaseAPIView): class UnsplashEndpoint(BaseAPIView): def get(self, request): + instance_configuration = InstanceConfiguration.objects.values("key", "value") + unsplash_access_key = get_configuration_value(instance_configuration, "UNSPLASH_ACCESS_KEY", os.environ.get("UNSPLASH_ACCESS_KEY")) + + # Check unsplash access key + if not unsplash_access_key: + return Response([], status=status.HTTP_200_OK) + + # Query parameters query = request.GET.get("query", False) page = request.GET.get("page", 1) per_page = request.GET.get("per_page", 20) url = ( - f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}" + f"https://api.unsplash.com/search/photos/?client_id={unsplash_access_key}&query={query}&page=${page}&per_page={per_page}" if query - else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}" + else f"https://api.unsplash.com/photos/?client_id={unsplash_access_key}&page={page}&per_page={per_page}" ) headers = { diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index 789f654b5..e7605bf14 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -298,6 +298,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet): current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder ) + issue.delete() issue_activity.delay( type="issue.activity.deleted", requested_data=json.dumps({"issue_id": str(pk)}), @@ -307,7 +308,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet): current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) - issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -596,7 +596,7 @@ class IssueActivityEndpoint(BaseAPIView): class IssueCommentViewSet(WebhookMixin, BaseViewSet): serializer_class = IssueCommentSerializer model = IssueComment - webhook_event = "issue-comment" + webhook_event = "issue_comment" permission_classes = [ ProjectLitePermission, ] diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py index 28986ea0f..4e10a0629 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -461,7 +461,6 @@ class ModuleIssueViewSet(BaseViewSet): module_issue = ModuleIssue.objects.get( workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk ) - module_issue.delete() issue_activity.delay( type="module.activity.deleted", requested_data=json.dumps( @@ -471,11 +470,12 @@ class ModuleIssueViewSet(BaseViewSet): } ), actor_id=str(request.user.id), - issue_id=str(pk), + issue_id=str(module_issue.issue_id), project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), ) + module_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/user.py b/apiserver/plane/app/views/user.py index ed1178886..e45a3e2f4 100644 --- a/apiserver/plane/app/views/user.py +++ b/apiserver/plane/app/views/user.py @@ -48,7 +48,7 @@ class UserEndpoint(BaseViewSet): if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists(): return Response( { - "error": "User cannot deactivate account as user is active in some workspaces" + "error": "You cannot deactivate account as you are a member in some workspaces." }, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/app/views/webhook.py b/apiserver/plane/app/views/webhook.py index 74d23dd91..48608d583 100644 --- a/apiserver/plane/app/views/webhook.py +++ b/apiserver/plane/app/views/webhook.py @@ -20,9 +20,10 @@ class WebhookEndpoint(BaseAPIView): def post(self, request, slug): workspace = Workspace.objects.get(slug=slug) - try: - serializer = WebhookSerializer(data=request.data) + serializer = WebhookSerializer( + data=request.data, context={"request": request} + ) if serializer.is_valid(): serializer.save(workspace_id=workspace.id) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -79,6 +80,7 @@ class WebhookEndpoint(BaseAPIView): serializer = WebhookSerializer( webhook, data=request.data, + context={request: request}, partial=True, fields=( "id", diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index 637fc95b5..56f567bf4 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -590,7 +590,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): member_with_role=Count( "project_projectmember", filter=Q( - project_projectmember__member_id=request.user.id, + project_projectmember__member_id=workspace_member.id, project_projectmember__role=20, ), ), @@ -600,7 +600,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): ): return Response( { - "error": "User is part of some projects where they are the only admin you should leave that project first" + "error": "User is a part of some projects where they are the only admin, they should either leave that project or promote another user to admin." }, status=status.HTTP_400_BAD_REQUEST, ) @@ -635,7 +635,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): ): return Response( { - "error": "You cannot leave the workspace as your the only admin of the workspace you will have to either delete the workspace or create an another admin" + "error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin." }, status=status.HTTP_400_BAD_REQUEST, ) @@ -656,7 +656,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): ): return Response( { - "error": "User is part of some projects where they are the only admin you should leave that project first" + "error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin." }, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/bgtasks/email_verification_task.py b/apiserver/plane/bgtasks/email_verification_task.py index ba4ce6490..3a9f52a68 100644 --- a/apiserver/plane/bgtasks/email_verification_task.py +++ b/apiserver/plane/bgtasks/email_verification_task.py @@ -21,8 +21,6 @@ def email_verification(first_name, email, token, current_site): realtivelink = "/request-email-verification/" + "?token=" + str(token) abs_url = current_site + realtivelink - from_email_string = settings.EMAIL_FROM - subject = "Verify your Email!" context = { diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index b924ad3a2..7eeec3dc4 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -19,8 +19,6 @@ def forgot_password(first_name, email, uidb64, token, current_site): realtivelink = f"/accounts/reset-password/?uidb64={uidb64}&token={token}" abs_url = current_site + realtivelink - from_email_string = settings.EMAIL_FROM - subject = "Reset Your Password - Plane" context = { diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 0c2199e44..4bc27d3ee 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -190,6 +190,7 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi issue_activities_created) if issue_activities_created is not None else None ) if type not in [ + "issue.activity.deleted", "cycle.activity.created", "cycle.activity.deleted", "module.activity.created", diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index 311ccec0a..9fd3dd904 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -25,8 +25,6 @@ def project_invitation(email, project_id, token, current_site, invitor): relativelink = f"/project-invitations/?invitation_id={project_member_invite.id}&email={email}&slug={project.workspace.slug}&project_id={str(project_id)}" abs_url = current_site + relativelink - from_email_string = settings.EMAIL_FROM - subject = f"{user.first_name or user.display_name or user.email} invited you to join {project.name} on Plane" context = { diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index 57f94dc03..606b9646c 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -2,15 +2,63 @@ import requests import uuid import hashlib import json +import hmac # Django imports from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from celery import shared_task from sentry_sdk import capture_exception -from plane.db.models import Webhook, WebhookLog +from plane.db.models import ( + Webhook, + WebhookLog, + Project, + Issue, + Cycle, + Module, + ModuleIssue, + CycleIssue, + IssueComment, +) +from plane.api.serializers import ( + ProjectSerializer, + IssueSerializer, + CycleSerializer, + ModuleSerializer, + CycleIssueSerializer, + ModuleIssueSerializer, + IssueCommentSerializer, +) + +SERIALIZER_MAPPER = { + "project": ProjectSerializer, + "issue": IssueSerializer, + "cycle": CycleSerializer, + "module": ModuleSerializer, + "cycle_issue": CycleIssueSerializer, + "module_issue": ModuleIssueSerializer, + "issue_comment": IssueCommentSerializer, +} + +MODEL_MAPPER = { + "project": Project, + "issue": Issue, + "cycle": Cycle, + "module": Module, + "cycle_issue": CycleIssue, + "module_issue": ModuleIssue, + "issue_comment": IssueComment, +} + + +def get_model_data(event, event_id): + model = MODEL_MAPPER.get(event) + queryset = model.objects.get(pk=event_id) + serializer = SERIALIZER_MAPPER.get(event) + return serializer(queryset).data @shared_task( @@ -20,7 +68,7 @@ from plane.db.models import Webhook, WebhookLog max_retries=5, retry_jitter=True, ) -def webhook_task(self, webhook, slug, event, event_data, action): +def webhook_task(self, webhook, slug, event, event_id, action): try: webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) @@ -31,19 +79,26 @@ def webhook_task(self, webhook, slug, event, event_data, action): "X-Plane-Event": event, } - # Your secret key + event_data = get_model_data(event=event, event_id=event_id) + + # # Your secret key + event_data = ( + json.loads(json.dumps(event_data, cls=DjangoJSONEncoder)) + if event_data is not None + else None + ) + + # Use HMAC for generating signature if webhook.secret_key: - # Concatenate the data and the secret key - message = event_data + webhook.secret_key - - # Create a SHA-256 hash of the message - sha256 = hashlib.sha256() - sha256.update(message.encode("utf-8")) - signature = sha256.hexdigest() + event_data_json = json.dumps(event_data) if event_data is not None else '{}' + hmac_signature = hmac.new( + webhook.secret_key.encode("utf-8"), + event_data_json.encode("utf-8"), + hashlib.sha256 + ) + signature = hmac_signature.hexdigest() headers["X-Plane-Signature"] = signature - event_data = json.loads(event_data) if event_data is not None else None - action = { "POST": "create", "PATCH": "update", @@ -103,6 +158,7 @@ def webhook_task(self, webhook, slug, event, event_data, action): raise requests.RequestException() except Exception as e: + print(e) if settings.DEBUG: print(e) capture_exception(e) @@ -110,7 +166,7 @@ def webhook_task(self, webhook, slug, event, event_data, action): @shared_task() -def send_webhook(event, event_data, action, slug): +def send_webhook(event, event_id, action, slug): try: webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) @@ -130,7 +186,7 @@ def send_webhook(event, event_data, action, slug): webhooks = webhooks.filter(issue_comment=True) for webhook in webhooks: - webhook_task.delay(webhook.id, slug, event, event_data, action) + webhook_task.delay(webhook.id, slug, event, event_id, action) except Exception as e: if settings.DEBUG: diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 7be1dbf60..767ef07fd 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -32,9 +32,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): # The complete url including the domain abs_url = current_site + relative_link - # The email from - from_email_string = settings.EMAIL_FROM - # Subject of the email subject = f"{user.first_name or user.display_name or user.email} invited you to join {workspace.name} on Plane" diff --git a/apiserver/plane/db/models/webhook.py b/apiserver/plane/db/models/webhook.py index 6698ec5b0..ea2b508e5 100644 --- a/apiserver/plane/db/models/webhook.py +++ b/apiserver/plane/db/models/webhook.py @@ -16,7 +16,6 @@ def generate_token(): def validate_schema(value): parsed_url = urlparse(value) - print(parsed_url) if parsed_url.scheme not in ["http", "https"]: raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.") diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index 9e3e253ad..c0df0d154 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -30,9 +30,11 @@ class Command(BaseCommand): "EMAIL_USE_TLS": os.environ.get("EMAIL_USE_TLS", "1"), "EMAIL_USE_SSL": os.environ.get("EMAIL_USE_SSL", "0"), # Open AI Settings - "OPENAI_API_BASE": os.environ.get("", "https://api.openai.com/v1"), - "OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "sk-"), + "OPENAI_API_BASE": os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"), + "OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", ""), "GPT_ENGINE": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), + # Unsplash Access Key + "UNSPLASH_ACCESS_KEY": os.environ.get("UNSPLASH_ACESS_KEY", "") } for key, value in config_keys.items(): diff --git a/packages/editor/document-editor/src/ui/components/alert-label.tsx b/packages/editor/document-editor/src/ui/components/alert-label.tsx index 7246647bc..0f0a238ba 100644 --- a/packages/editor/document-editor/src/ui/components/alert-label.tsx +++ b/packages/editor/document-editor/src/ui/components/alert-label.tsx @@ -1,19 +1,21 @@ -import { Icon } from "lucide-react" +import { Icon } from "lucide-react"; interface IAlertLabelProps { - Icon: Icon, - backgroundColor: string, - textColor?: string, - label: string, + Icon?: Icon; + backgroundColor: string; + textColor?: string; + label: string; } -export const AlertLabel = ({ Icon, backgroundColor,textColor, label }: IAlertLabelProps) => { +export const AlertLabel = (props: IAlertLabelProps) => { + const { Icon, backgroundColor, textColor, label } = props; return ( -
- - {label} +
+ {Icon && } + {label}
- ) - -} + ); +}; diff --git a/packages/editor/document-editor/src/ui/components/content-browser.tsx b/packages/editor/document-editor/src/ui/components/content-browser.tsx index 755d67b2d..bb0e3fb8b 100644 --- a/packages/editor/document-editor/src/ui/components/content-browser.tsx +++ b/packages/editor/document-editor/src/ui/components/content-browser.tsx @@ -8,33 +8,33 @@ interface ContentBrowserProps { markings: IMarking[]; } -export const ContentBrowser = ({ - editor, - markings, -}: ContentBrowserProps) => ( -
-

- Table of Contents -

-
- {markings.length !== 0 ? ( - markings.map((marking) => - marking.level === 1 ? ( - scrollSummary(editor, marking)} - heading={marking.text} - /> +export const ContentBrowser = (props: ContentBrowserProps) => { + const { editor, markings } = props; + + return ( +
+

Table of Contents

+
+ {markings.length !== 0 ? ( + markings.map((marking) => + marking.level === 1 ? ( + scrollSummary(editor, marking)} + heading={marking.text} + /> + ) : ( + scrollSummary(editor, marking)} + subHeading={marking.text} + /> + ), + ) ) : ( - scrollSummary(editor, marking)} - subHeading={marking.text} - /> - ) - ) - ) : ( -

- {"Headings will be displayed here for Navigation"} -

- )} -
-); +

+ Headings will be displayed here for navigation +

+ )} +
+
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/editor-header.tsx b/packages/editor/document-editor/src/ui/components/editor-header.tsx index 32ebe43c9..e16f6768d 100644 --- a/packages/editor/document-editor/src/ui/components/editor-header.tsx +++ b/packages/editor/document-editor/src/ui/components/editor-header.tsx @@ -1,79 +1,90 @@ -import { Editor } from "@tiptap/react" -import { Lock, ArchiveIcon, MenuSquare } from "lucide-react" -import { useRef, useState } from "react" -import { usePopper } from "react-popper" -import { IMarking, UploadImage } from ".." -import { FixedMenu } from "../menu" -import { DocumentDetails } from "../types/editor-types" -import { AlertLabel } from "./alert-label" -import { ContentBrowser } from "./content-browser" -import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "./vertical-dropdown-menu" +import { Editor } from "@tiptap/react"; +import { Archive, Info, Lock } from "lucide-react"; +import { IMarking, UploadImage } from ".."; +import { FixedMenu } from "../menu"; +import { DocumentDetails } from "../types/editor-types"; +import { AlertLabel } from "./alert-label"; +import { + IVerticalDropdownItemProps, + VerticalDropdownMenu, +} from "./vertical-dropdown-menu"; +import { SummaryPopover } from "./summary-popover"; +import { InfoPopover } from "./info-popover"; interface IEditorHeader { - editor: Editor, - KanbanMenuOptions: IVerticalDropdownItemProps[], - sidePeakVisible: boolean, - setSidePeakVisible: (currentState: boolean) => void, - markings: IMarking[], - isLocked: boolean, - isArchived: boolean, - archivedAt?: Date, - readonly: boolean, - uploadFile?: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, - documentDetails: DocumentDetails + editor: Editor; + KanbanMenuOptions: IVerticalDropdownItemProps[]; + sidePeekVisible: boolean; + setSidePeekVisible: (sidePeekState: boolean) => void; + markings: IMarking[]; + isLocked: boolean; + isArchived: boolean; + archivedAt?: Date; + readonly: boolean; + uploadFile?: UploadImage; + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void; + documentDetails: DocumentDetails; } -export const EditorHeader = ({ documentDetails, archivedAt, editor, sidePeakVisible, readonly, setSidePeakVisible, markings, uploadFile, setIsSubmitting, KanbanMenuOptions, isArchived, isLocked }: IEditorHeader) => { - - const summaryMenuRef = useRef(null); - const summaryButtonRef = useRef(null); - const [summaryPopoverVisible, setSummaryPopoverVisible] = useState(false); - - const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } = usePopper(summaryButtonRef.current, summaryMenuRef.current, { - placement: "bottom-start" - }) +export const EditorHeader = (props: IEditorHeader) => { + const { + documentDetails, + archivedAt, + editor, + sidePeekVisible, + readonly, + setSidePeekVisible, + markings, + uploadFile, + setIsSubmitting, + KanbanMenuOptions, + isArchived, + isLocked, + } = props; return ( +
+
+ +
-
-
-
-
setSummaryPopoverVisible(true)} - onMouseLeave={() => setSummaryPopoverVisible(false)} - > - - {summaryPopoverVisible && -
- -
- } -
- {isLocked && } - {(isArchived && archivedAt) && } -
+
+ {!readonly && uploadFile && ( + + )} +
- {(!readonly && uploadFile) && } -
- {!isArchived &&

{`Last updated at ${new Date(documentDetails.last_updated_at).toLocaleString()}`}

} - -
+
+ {isLocked && ( + + )} + {isArchived && archivedAt && ( + + )} + {!isArchived && } +
- ) - -} + ); +}; diff --git a/packages/editor/document-editor/src/ui/components/index.ts b/packages/editor/document-editor/src/ui/components/index.ts new file mode 100644 index 000000000..1496a3cf4 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/index.ts @@ -0,0 +1,9 @@ +export * from "./alert-label"; +export * from "./content-browser"; +export * from "./editor-header"; +export * from "./heading-component"; +export * from "./info-popover"; +export * from "./page-renderer"; +export * from "./summary-popover"; +export * from "./summary-side-bar"; +export * from "./vertical-dropdown-menu"; diff --git a/packages/editor/document-editor/src/ui/components/info-popover.tsx b/packages/editor/document-editor/src/ui/components/info-popover.tsx new file mode 100644 index 000000000..d42e32a8d --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/info-popover.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { usePopper } from "react-popper"; +import { Calendar, History, Info } from "lucide-react"; +// types +import { DocumentDetails } from "../types/editor-types"; + +type Props = { + documentDetails: DocumentDetails; +}; + +// function to render a Date in the format- 25 May 2023 at 2:53PM +const renderDate = (date: Date): string => { + const options: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "long", + year: "numeric", + hour: "numeric", + minute: "numeric", + hour12: true, + }; + + const formattedDate: string = new Intl.DateTimeFormat( + "en-US", + options, + ).format(date); + + return formattedDate; +}; + +export const InfoPopover: React.FC = (props) => { + const { documentDetails } = props; + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const [referenceElement, setReferenceElement] = + useState(null); + const [popperElement, setPopperElement] = useState( + null, + ); + + const { styles: infoPopoverStyles, attributes: infoPopoverAttributes } = + usePopper(referenceElement, popperElement, { + placement: "bottom-start", + }); + + return ( +
setIsPopoverOpen(true)} + onMouseLeave={() => setIsPopoverOpen(false)} + > + + {isPopoverOpen && ( +
+
+
Last updated on
+
+ + {renderDate(new Date(documentDetails.last_updated_at))} +
+
+
+
Created on
+
+ + {renderDate(new Date(documentDetails.created_on))} +
+
+
+ )} +
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/document-editor/src/ui/components/page-renderer.tsx index aca50f3ff..194152dd3 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -1,33 +1,35 @@ -import { EditorContainer, EditorContentWrapper } from "@plane/editor-core" -import { Editor } from "@tiptap/react" -import { DocumentDetails } from "../types/editor-types" +import { EditorContainer, EditorContentWrapper } from "@plane/editor-core"; +import { Editor } from "@tiptap/react"; +import { DocumentDetails } from "../types/editor-types"; interface IPageRenderer { - sidePeakVisible: boolean, - documentDetails: DocumentDetails , - editor: Editor, - editorClassNames: string, - editorContentCustomClassNames?: string + documentDetails: DocumentDetails; + editor: Editor; + editorClassNames: string; + editorContentCustomClassNames?: string; } -export const PageRenderer = ({ sidePeakVisible, documentDetails, editor, editorClassNames, editorContentCustomClassNames }: IPageRenderer) => { +export const PageRenderer = (props: IPageRenderer) => { + const { + documentDetails, + editor, + editorClassNames, + editorContentCustomClassNames, + } = props; + return ( -
-
-
-

{documentDetails.title}

-
-
-
- -
- -
-
-
+
+

+ {documentDetails.title} +

+
+ + +
- ) -} + ); +}; diff --git a/packages/editor/document-editor/src/ui/components/popover.tsx b/packages/editor/document-editor/src/ui/components/popover.tsx deleted file mode 100644 index 8c587b603..000000000 --- a/packages/editor/document-editor/src/ui/components/popover.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React, { Fragment, useState } from "react"; -import { usePopper } from "react-popper"; -import { Popover, Transition } from "@headlessui/react"; -import { Placement } from "@popperjs/core"; -// ui -import { Button } from "@plane/ui"; -// icons -import { ChevronUp, MenuIcon } from "lucide-react"; - -type Props = { - children: React.ReactNode; - title?: string; - placement?: Placement; -}; - -export const SummaryPopover: React.FC = (props) => { - const { children, title = "SummaryPopover", placement } = props; - - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: placement ?? "auto", - }); - - return ( - - {({ open }) => { - if (open) { - } - return ( - <> - - - - - -
-
{children}
-
-
-
- - ); - }} -
- ); -}; diff --git a/packages/editor/document-editor/src/ui/components/summary-popover.tsx b/packages/editor/document-editor/src/ui/components/summary-popover.tsx new file mode 100644 index 000000000..7c85ed945 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/summary-popover.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { Editor } from "@tiptap/react"; +import { usePopper } from "react-popper"; +import { List } from "lucide-react"; +// components +import { ContentBrowser } from "./content-browser"; +// types +import { IMarking } from ".."; + +type Props = { + editor: Editor; + markings: IMarking[]; + sidePeekVisible: boolean; + setSidePeekVisible: (sidePeekState: boolean) => void; +}; + +export const SummaryPopover: React.FC = (props) => { + const { editor, markings, sidePeekVisible, setSidePeekVisible } = props; + + const [referenceElement, setReferenceElement] = + useState(null); + const [popperElement, setPopperElement] = useState( + null, + ); + + const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } = + usePopper(referenceElement, popperElement, { + placement: "bottom-start", + }); + + return ( +
+ + {!sidePeekVisible && ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx b/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx index 304c80018..dd98e0572 100644 --- a/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx +++ b/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx @@ -1,18 +1,25 @@ -import { Editor } from "@tiptap/react" -import { IMarking } from ".." -import { ContentBrowser } from "./content-browser" +import { Editor } from "@tiptap/react"; +import { IMarking } from ".."; +import { ContentBrowser } from "./content-browser"; interface ISummarySideBarProps { - editor: Editor, - markings: IMarking[], - sidePeakVisible: boolean + editor: Editor; + markings: IMarking[]; + sidePeekVisible: boolean; } -export const SummarySideBar = ({ editor, markings, sidePeakVisible }: ISummarySideBarProps) => { +export const SummarySideBar = ({ + editor, + markings, + sidePeekVisible, +}: ISummarySideBarProps) => { return ( - -
+
- ) -} + ); +}; diff --git a/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx b/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx index c28cb4d32..cf7ee7db1 100644 --- a/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx +++ b/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx @@ -1,41 +1,52 @@ -import { Button, CustomMenu } from "@plane/ui" -import { ChevronUp, Icon, MoreVertical } from "lucide-react" +import { Button, CustomMenu } from "@plane/ui"; +import { ChevronUp, Icon, MoreVertical } from "lucide-react"; - -type TMenuItems = "archive_page" | "unarchive_page" | "lock_page" | "unlock_page" | "copy_markdown" | "close_page" | "copy_page_link" | "duplicate_page" +type TMenuItems = + | "archive_page" + | "unarchive_page" + | "lock_page" + | "unlock_page" + | "copy_markdown" + | "close_page" + | "copy_page_link" + | "duplicate_page"; export interface IVerticalDropdownItemProps { - key: number, - type: TMenuItems, - Icon: Icon, - label: string, - action: () => Promise | void + key: number; + type: TMenuItems; + Icon: Icon; + label: string; + action: () => Promise | void; } export interface IVerticalDropdownMenuProps { - items: IVerticalDropdownItemProps[], + items: IVerticalDropdownItemProps[]; } -const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => { - +const VerticalDropdownItem = ({ + Icon, + label, + action, +}: IVerticalDropdownItemProps) => { return ( - - + + +
{label}
- ) -} + ); +}; export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => { - return ( - - }> + } + > {items.map((item, index) => ( { /> ))} - ) -} + ); +}; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index be75ff8fb..f46c5ca47 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -1,34 +1,40 @@ -"use client" -import React, { useState } from 'react'; -import { cn, getEditorClassNames, useEditor } from '@plane/editor-core'; -import { DocumentEditorExtensions } from './extensions'; -import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from './types/menu-actions'; -import { EditorHeader } from './components/editor-header'; -import { useEditorMarkings } from './hooks/use-editor-markings'; -import { SummarySideBar } from './components/summary-side-bar'; -import { DocumentDetails } from './types/editor-types'; -import { PageRenderer } from './components/page-renderer'; -import { getMenuOptions } from './utils/menu-options'; -import { useRouter } from 'next/router'; +"use client"; +import React, { useState } from "react"; +import { cn, getEditorClassNames, useEditor } from "@plane/editor-core"; +import { DocumentEditorExtensions } from "./extensions"; +import { + IDuplicationConfig, + IPageArchiveConfig, + IPageLockConfig, +} from "./types/menu-actions"; +import { EditorHeader } from "./components/editor-header"; +import { useEditorMarkings } from "./hooks/use-editor-markings"; +import { SummarySideBar } from "./components/summary-side-bar"; +import { DocumentDetails } from "./types/editor-types"; +import { PageRenderer } from "./components/page-renderer"; +import { getMenuOptions } from "./utils/menu-options"; +import { useRouter } from "next/router"; export type UploadImage = (file: File) => Promise; export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; interface IDocumentEditor { - documentDetails: DocumentDetails, + documentDetails: DocumentDetails; value: string; uploadFile: UploadImage; deleteFile: DeleteImage; customClassName?: string; editorContentCustomClassNames?: string; onChange: (json: any, html: string) => void; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void; setShouldShowAlert?: (showAlert: boolean) => void; forwardedRef?: any; debouncedUpdatesEnabled?: boolean; - duplicationConfig?: IDuplicationConfig, - pageLockConfig?: IPageLockConfig, - pageArchiveConfig?: IPageArchiveConfig + duplicationConfig?: IDuplicationConfig; + pageLockConfig?: IPageLockConfig; + pageArchiveConfig?: IPageArchiveConfig; } interface DocumentEditorProps extends IDocumentEditor { forwardedRef?: React.Ref; @@ -40,10 +46,10 @@ interface EditorHandle { } export interface IMarking { - type: "heading", - level: number, - text: string, - sequence: number + type: "heading"; + level: number; + text: string; + sequence: number; } const DocumentEditor = ({ @@ -60,21 +66,20 @@ const DocumentEditor = ({ forwardedRef, duplicationConfig, pageLockConfig, - pageArchiveConfig + pageArchiveConfig, }: IDocumentEditor) => { - // const [alert, setAlert] = useState("") - const { markings, updateMarkings } = useEditorMarkings() - const [sidePeakVisible, setSidePeakVisible] = useState(true) - const router = useRouter() + const { markings, updateMarkings } = useEditorMarkings(); + const [sidePeekVisible, setSidePeekVisible] = useState(true); + const router = useRouter(); const editor = useEditor({ onChange(json, html) { - updateMarkings(json) - onChange(json, html) + updateMarkings(json); + onChange(json, html); }, onStart(json) { - updateMarkings(json) + updateMarkings(json); }, debouncedUpdatesEnabled, setIsSubmitting, @@ -87,65 +92,66 @@ const DocumentEditor = ({ }); if (!editor) { - return null + return null; } - const KanbanMenuOptions = getMenuOptions( - { - editor: editor, - router: router, - duplicationConfig: duplicationConfig, - pageLockConfig: pageLockConfig, - pageArchiveConfig: pageArchiveConfig, - } - ) - const editorClassNames = getEditorClassNames({ noBorder: true, borderOnFocus: false, customClassName }); + const KanbanMenuOptions = getMenuOptions({ + editor: editor, + router: router, + duplicationConfig: duplicationConfig, + pageLockConfig: pageLockConfig, + pageArchiveConfig: pageArchiveConfig, + }); + const editorClassNames = getEditorClassNames({ + noBorder: true, + borderOnFocus: false, + customClassName, + }); if (!editor) return null; return ( -
-
- -
-
-
+
+ setSidePeekVisible(val)} + markings={markings} + uploadFile={uploadFile} + setIsSubmitting={setIsSubmitting} + isLocked={!pageLockConfig ? false : pageLockConfig.is_locked} + isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived} + archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at} + documentDetails={documentDetails} + /> +
+
+
+
- {/* Page Element */}
+
); -} +}; -const DocumentEditorWithRef = React.forwardRef((props, ref) => ( - -)); +const DocumentEditorWithRef = React.forwardRef( + (props, ref) => , +); DocumentEditorWithRef.displayName = "DocumentEditorWithRef"; -export { DocumentEditor, DocumentEditorWithRef } +export { DocumentEditor, DocumentEditorWithRef }; diff --git a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx index 2cd07ec14..8080f7c63 100644 --- a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx +++ b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx @@ -1,7 +1,22 @@ import { Editor } from "@tiptap/react"; -import { BoldIcon, Heading1, Heading2, Heading3 } from "lucide-react"; +import { BoldIcon } from "lucide-react"; -import { BoldItem, BulletListItem, cn, CodeItem, ImageItem, ItalicItem, NumberedListItem, QuoteItem, StrikeThroughItem, TableItem, UnderLineItem, HeadingOneItem, HeadingTwoItem, HeadingThreeItem } from "@plane/editor-core"; +import { + BoldItem, + BulletListItem, + cn, + CodeItem, + ImageItem, + ItalicItem, + NumberedListItem, + QuoteItem, + StrikeThroughItem, + TableItem, + UnderLineItem, + HeadingOneItem, + HeadingTwoItem, + HeadingThreeItem, +} from "@plane/editor-core"; import { UploadImage } from ".."; export interface BubbleMenuItem { @@ -14,77 +29,69 @@ export interface BubbleMenuItem { type EditorBubbleMenuProps = { editor: Editor; uploadFile: UploadImage; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; -} + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void; +}; export const FixedMenu = (props: EditorBubbleMenuProps) => { + const { editor, uploadFile, setIsSubmitting } = props; + const basicMarkItems: BubbleMenuItem[] = [ - HeadingOneItem(props.editor), - HeadingTwoItem(props.editor), - HeadingThreeItem(props.editor), - BoldItem(props.editor), - ItalicItem(props.editor), - UnderLineItem(props.editor), - StrikeThroughItem(props.editor), + HeadingOneItem(editor), + HeadingTwoItem(editor), + HeadingThreeItem(editor), + BoldItem(editor), + ItalicItem(editor), + UnderLineItem(editor), + StrikeThroughItem(editor), ]; const listItems: BubbleMenuItem[] = [ - BulletListItem(props.editor), - NumberedListItem(props.editor), + BulletListItem(editor), + NumberedListItem(editor), ]; const userActionItems: BubbleMenuItem[] = [ - QuoteItem(props.editor), - CodeItem(props.editor), + QuoteItem(editor), + CodeItem(editor), ]; const complexItems: BubbleMenuItem[] = [ - TableItem(props.editor), - ImageItem(props.editor, props.uploadFile, props.setIsSubmitting), + TableItem(editor), + ImageItem(editor, uploadFile, setIsSubmitting), ]; - // const handleAccessChange = (accessKey: string) => { - // props.commentAccessSpecifier?.onAccessChange(accessKey); - // }; - - return ( -
-
- {basicMarkItems.map((item, index) => ( +
+
+ {basicMarkItems.map((item) => ( ))}
-
- {listItems.map((item, index) => ( +
+ {listItems.map((item) => ( ))}
-
- {userActionItems.map((item, index) => ( +
+ {userActionItems.map((item) => ( ))}
-
- {complexItems.map((item, index) => ( +
+ {complexItems.map((item) => ( + + + ) : ( - + + + ) ) : ( diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx index e80c4609a..be9857dc8 100644 --- a/web/components/issues/draft-issue-form.tsx +++ b/web/components/issues/draft-issue-form.tsx @@ -31,6 +31,8 @@ import type { IUser, IIssue, ISearchIssueResponse } from "types"; // components import { RichTextEditorWithRef } from "@plane/rich-text-editor"; import useEditorSuggestions from "hooks/use-editor-suggestions"; +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; const aiService = new AIService(); const fileService = new FileService(); @@ -89,7 +91,7 @@ interface IssueFormProps { )[]; } -export const DraftIssueForm: FC = (props) => { +export const DraftIssueForm: FC = observer((props) => { const { handleFormSubmit, data, @@ -100,30 +102,30 @@ export const DraftIssueForm: FC = (props) => { createMore, setCreateMore, status, - user, fieldsToShow, handleDiscard, } = props; - + // states const [stateModal, setStateModal] = useState(false); const [labelModal, setLabelModal] = useState(false); const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); const [selectedParentIssue, setSelectedParentIssue] = useState(null); - const [gptAssistantModal, setGptAssistantModal] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - + // hooks const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {}); - + const { setToastAlert } = useToast(); + const editorSuggestions = useEditorSuggestions(); + // refs const editorRef = useRef(null); - + // router const router = useRouter(); const { workspaceSlug } = router.query; - - const { setToastAlert } = useToast(); - - const editorSuggestions = useEditorSuggestions(); - + // store + const { + appConfig: { envConfig }, + } = useMobxStore(); + // form info const { formState: { errors, isSubmitting }, handleSubmit, @@ -440,21 +442,23 @@ export const DraftIssueForm: FC = (props) => { /> )} /> - { - setGptAssistantModal(false); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - inset="top-2 left-0" - content="" - htmlContent={watch("description_html")} - onResponse={(response) => { - handleAiAssistance(response); - }} - projectId={projectId} - /> + {envConfig?.has_openai_configured && ( + { + setGptAssistantModal(false); + // this is done so that the title do not reset after gpt popover closed + reset(getValues()); + }} + inset="top-2 left-0" + content="" + htmlContent={watch("description_html")} + onResponse={(response) => { + handleAiAssistance(response); + }} + projectId={projectId} + /> + )}
)}
@@ -623,4 +627,4 @@ export const DraftIssueForm: FC = (props) => { ); -}; +}); diff --git a/web/components/issues/form.tsx b/web/components/issues/form.tsx index f33d8e6be..454647bfb 100644 --- a/web/components/issues/form.tsx +++ b/web/components/issues/form.tsx @@ -94,28 +94,29 @@ export const IssueForm: FC = observer((props) => { fieldsToShow, handleFormDirty, } = props; - + // states const [stateModal, setStateModal] = useState(false); const [labelModal, setLabelModal] = useState(false); const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); const [selectedParentIssue, setSelectedParentIssue] = useState(null); - const [gptAssistantModal, setGptAssistantModal] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - + // refs const editorRef = useRef(null); - + // router const router = useRouter(); const { workspaceSlug } = router.query; - - const { user: userStore } = useMobxStore(); - + // store + const { + user: userStore, + appConfig: { envConfig }, + } = useMobxStore(); const user = userStore.currentUser; - + console.log("envConfig", envConfig); + // hooks const editorSuggestion = useEditorSuggestions(); - const { setToastAlert } = useToast(); - + // form info const { formState: { errors, isSubmitting, isDirty }, handleSubmit, @@ -396,21 +397,23 @@ export const IssueForm: FC = observer((props) => { /> )} /> - { - setGptAssistantModal(false); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - inset="top-2 left-0" - content="" - htmlContent={watch("description_html")} - onResponse={(response) => { - handleAiAssistance(response); - }} - projectId={projectId} - /> + {envConfig?.has_openai_configured && ( + { + setGptAssistantModal(false); + // this is done so that the title do not reset after gpt popover closed + reset(getValues()); + }} + inset="top-2 left-0" + content="" + htmlContent={watch("description_html")} + onResponse={(response) => { + handleAiAssistance(response); + }} + projectId={projectId} + /> + )}
)}
diff --git a/web/components/issues/issue-peek-overview/view.tsx b/web/components/issues/issue-peek-overview/view.tsx index 3d29c1545..88afbd487 100644 --- a/web/components/issues/issue-peek-overview/view.tsx +++ b/web/components/issues/issue-peek-overview/view.tsx @@ -225,6 +225,7 @@ export const IssueView: FC = observer((props) => { size="sm" prependIcon={} variant="outline-primary" + className="hover:!bg-custom-primary-100/20" onClick={() => issueSubscription && issueSubscription.subscribed ? issueSubscriptionRemove() diff --git a/web/components/issues/sidebar.tsx b/web/components/issues/sidebar.tsx index e67b73baa..c0bb2da18 100644 --- a/web/components/issues/sidebar.tsx +++ b/web/components/issues/sidebar.tsx @@ -33,7 +33,7 @@ import { import { CustomDatePicker } from "components/ui"; // icons import { Bell, CalendarDays, LinkIcon, Plus, Signal, Tag, Trash2, Triangle, User2 } from "lucide-react"; -import { ContrastIcon, DiceIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui"; +import { Button, ContrastIcon, DiceIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui"; // helpers import { copyTextToClipboard } from "helpers/string.helper"; // types @@ -273,17 +273,18 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { !issueDetail?.assignees.includes(user?.id ?? "") && !router.pathname.includes("[archivedIssueId]") && (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && ( - + )} {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
- setGptAssistantModal(false)} - inset="top-8 left-0" - content={watch("description_html")} - htmlContent={watch("description_html")} - onResponse={(response) => { - if (data && handleAiAssistance) { - handleAiAssistance(response); - editorRef.current?.setEditorValue(`${watch("description_html")}

${response}

` ?? ""); - } else { - setValue("description", {}); - setValue("description_html", `${watch("description_html")}

${response}

`); + {envConfig?.has_openai_configured && ( + setGptAssistantModal(false)} + inset="top-8 left-0" + content={watch("description_html")} + htmlContent={watch("description_html")} + onResponse={(response) => { + if (data && handleAiAssistance) { + handleAiAssistance(response); + editorRef.current?.setEditorValue(`${watch("description_html")}

${response}

` ?? ""); + } else { + setValue("description", {}); + setValue("description_html", `${watch("description_html")}

${response}

`); - editorRef.current?.setEditorValue(watch("description_html") ?? ""); - } - }} - projectId={projectId?.toString() ?? ""} - /> + editorRef.current?.setEditorValue(watch("description_html") ?? ""); + } + }} + projectId={projectId?.toString() ?? ""} + /> + )}
); }; diff --git a/web/components/pages/create-update-page-modal.tsx b/web/components/pages/create-update-page-modal.tsx index 62e3d244e..7816e8dce 100644 --- a/web/components/pages/create-update-page-modal.tsx +++ b/web/components/pages/create-update-page-modal.tsx @@ -38,7 +38,7 @@ export const CreateUpdatePageModal: FC = (props) => { const createProjectPage = async (payload: IPage) => { if (!workspaceSlug) return; - createPage(workspaceSlug.toString(), projectId, payload) + await createPage(workspaceSlug.toString(), projectId, payload) .then((res) => { router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`); onClose(); @@ -67,7 +67,7 @@ export const CreateUpdatePageModal: FC = (props) => { const updateProjectPage = async (payload: IPage) => { if (!data || !workspaceSlug) return; - return updatePage(workspaceSlug.toString(), projectId, data.id, payload) + await updatePage(workspaceSlug.toString(), projectId, data.id, payload) .then((res) => { onClose(); setToastAlert({ diff --git a/web/components/pages/page-form.tsx b/web/components/pages/page-form.tsx index 18366286c..594390255 100644 --- a/web/components/pages/page-form.tsx +++ b/web/components/pages/page-form.tsx @@ -1,9 +1,9 @@ -import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; // ui import { Button, Input, Tooltip } from "@plane/ui"; // types import { IPage } from "types"; +// constants import { PAGE_ACCESS_SPECIFIERS } from "constants/page"; type Props = { @@ -18,31 +18,21 @@ const defaultValues = { access: 0, }; -export const PageForm: React.FC = ({ handleFormSubmit, handleClose, data }) => { +export const PageForm: React.FC = (props) => { + const { handleFormSubmit, handleClose, data } = props; + const { formState: { errors, isSubmitting }, handleSubmit, control, - reset, } = useForm({ - defaultValues, + defaultValues: { ...defaultValues, ...data }, }); const handleCreateUpdatePage = async (formData: IPage) => { await handleFormSubmit(formData); - - reset({ - ...defaultValues, - }); }; - useEffect(() => { - reset({ - ...defaultValues, - ...data, - }); - }, [data, reset]); - return (
diff --git a/web/components/pages/pages-list/recent-pages-list.tsx b/web/components/pages/pages-list/recent-pages-list.tsx index 7122fa071..0f213db83 100644 --- a/web/components/pages/pages-list/recent-pages-list.tsx +++ b/web/components/pages/pages-list/recent-pages-list.tsx @@ -38,8 +38,9 @@ export const RecentPagesList: FC = observer(() => { <> {Object.keys(recentProjectPages).map((key) => { if (recentProjectPages[key].length === 0) return null; + return ( -
+

{replaceUnderscoreIfSnakeCase(key)}

diff --git a/web/components/project/card.tsx b/web/components/project/card.tsx index 9bfe6b7a7..25faed4b3 100644 --- a/web/components/project/card.tsx +++ b/web/components/project/card.tsx @@ -113,7 +113,7 @@ export const ProjectCard: React.FC = observer((props) => { className="absolute top-0 left-0 h-full w-full object-cover rounded-t" /> -
+
diff --git a/web/components/project/join-project-modal.tsx b/web/components/project/join-project-modal.tsx index 22fc2e9f5..67adc881d 100644 --- a/web/components/project/join-project-modal.tsx +++ b/web/components/project/join-project-modal.tsx @@ -21,22 +21,21 @@ export const JoinProjectModal: React.FC = (props) => { // states const [isJoiningLoading, setIsJoiningLoading] = useState(false); // store - const { project: projectStore } = useMobxStore(); + const { + project: { joinProject }, + } = useMobxStore(); // router const router = useRouter(); const handleJoin = () => { setIsJoiningLoading(true); - projectStore - .joinProject(workspaceSlug, [project.id]) + joinProject(workspaceSlug, [project.id]) .then(() => { - setIsJoiningLoading(false); - router.push(`/${workspaceSlug}/projects/${project.id}/issues`); handleClose(); }) - .catch(() => { + .finally(() => { setIsJoiningLoading(false); }); }; @@ -73,8 +72,9 @@ export const JoinProjectModal: React.FC = (props) => { Join Project?

- Are you sure you want to join the project {project?.name}? - Please click the 'Join Project' button below to continue. + Are you sure you want to join the project{" "} + {project?.name}? Please click the 'Join + Project' button below to continue.

diff --git a/web/components/project/member-list-item.tsx b/web/components/project/member-list-item.tsx index 84d29079c..308ca8827 100644 --- a/web/components/project/member-list-item.tsx +++ b/web/components/project/member-list-item.tsx @@ -11,7 +11,7 @@ import { ConfirmProjectMemberRemove } from "components/project"; // ui import { CustomSelect, Tooltip } from "@plane/ui"; // icons -import { ChevronDown, XCircle } from "lucide-react"; +import { ChevronDown, Dot, XCircle } from "lucide-react"; // constants import { ROLE } from "constants/workspace"; // types @@ -116,7 +116,15 @@ export const ProjectMemberListItem: React.FC = observer((props) => { ) : (

{member.display_name || member.email}

)} -

{member.email ?? member.display_name}

+
+

{member.display_name}

+ {isAdmin && ( + <> + +

{member.email}

+ + )} +
diff --git a/web/components/project/sidebar-list-item.tsx b/web/components/project/sidebar-list-item.tsx index 480a99a92..48c9d0b9e 100644 --- a/web/components/project/sidebar-list-item.tsx +++ b/web/components/project/sidebar-list-item.tsx @@ -1,11 +1,22 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; import { Disclosure, Transition } from "@headlessui/react"; import { observer } from "mobx-react-lite"; // icons -import { MoreVertical, PenSquare, LinkIcon, Star, FileText, Settings, Share2, LogOut, ChevronDown } from "lucide-react"; +import { + MoreVertical, + PenSquare, + LinkIcon, + Star, + FileText, + Settings, + Share2, + LogOut, + ChevronDown, + MoreHorizontal, +} from "lucide-react"; // hooks import useToast from "hooks/use-toast"; // helpers @@ -17,6 +28,7 @@ import { useMobxStore } from "lib/mobx/store-provider"; // components import { CustomMenu, Tooltip, ArchiveIcon, PhotoFilterIcon, DiceIcon, ContrastIcon, LayersIcon } from "@plane/ui"; import { LeaveProjectModal, PublishProjectModal } from "components/project"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; type Props = { project: IProject; @@ -72,12 +84,15 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { // states const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false); const [publishModalOpen, setPublishModal] = useState(false); + const [isMenuActive, setIsMenuActive] = useState(false); const isAdmin = project.member_role === 20; const isViewerOrGuest = project.member_role === 10 || project.member_role === 5; const isCollapsed = themeStore.sidebarCollapsed; + const actionSectionRef = useRef(null); + const handleAddToFavorites = () => { if (!workspaceSlug) return; @@ -110,6 +125,8 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { setLeaveProjectModal(false); }; + useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); + return ( <> setPublishModal(false)} /> @@ -120,7 +137,7 @@ export const ProjectSidebarListItem: React.FC = observer((props) => {
{provided && ( = observer((props) => { type="button" className={`absolute top-1/2 -translate-y-1/2 -left-2.5 hidden rounded p-0.5 text-custom-sidebar-text-400 ${ isCollapsed ? "" : "group-hover:!flex" - } ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`} + } ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""} ${ + isMenuActive ? "!flex" : "" + }`} {...provided?.dragHandleProps} > @@ -169,9 +188,9 @@ export const ProjectSidebarListItem: React.FC = observer((props) => {
{!isCollapsed && (
+ } + className={`hidden group-hover:block flex-shrink-0 ${isMenuActive ? "!block" : ""}`} buttonClassName="!text-custom-sidebar-text-400 hover:text-custom-sidebar-text-400" ellipsis placement="bottom-start" diff --git a/web/components/web-hooks/empty-webhooks.tsx b/web/components/web-hooks/empty-webhooks.tsx index d6ed6f2cd..d6a5d58de 100644 --- a/web/components/web-hooks/empty-webhooks.tsx +++ b/web/components/web-hooks/empty-webhooks.tsx @@ -1,28 +1,32 @@ -import { FC } from "react"; -import Link from "next/link"; -import { Button } from "@plane/ui"; +// next +import { useRouter } from "next/router"; import Image from "next/image"; -import EmptyWebhookLogo from "public/empty-state/issue.svg"; +// ui +import { Button } from "@plane/ui"; +// assets +import EmptyWebhook from "public/empty-state/web-hook.svg"; -interface IWebHookLists { - workspaceSlug: string; -} - -export const EmptyWebhooks: FC = (props) => { - const { workspaceSlug } = props; +export const EmptyWebhooks = () => { + const router = useRouter(); return ( -
-
- empty-webhook image - -
No Webhooks
-

Create webhooks to receive real-time updates and automate actions

- - - +
+
+ empty +
No Webhooks
+ { +

+ Create webhooks to receive real-time updates and automate actions +

+ } +
); diff --git a/web/components/web-hooks/webhooks-list-item.tsx b/web/components/web-hooks/webhooks-list-item.tsx index 4d99e1d22..549caf024 100644 --- a/web/components/web-hooks/webhooks-list-item.tsx +++ b/web/components/web-hooks/webhooks-list-item.tsx @@ -1,10 +1,10 @@ -import { FC, useState } from "react"; +import { FC } from "react"; import { ToggleSwitch } from "@plane/ui"; -import { Pencil, XCircle } from "lucide-react"; -import { IWebhook } from "types"; import Link from "next/link"; import { RootStore } from "store/root"; import { useMobxStore } from "lib/mobx/store-provider"; +// types +import { IWebhook } from "types"; interface IWebhookListItem { workspaceSlug: string; diff --git a/web/components/workspace/issues-stats.tsx b/web/components/workspace/issues-stats.tsx index 22e966ac8..aef5cd108 100644 --- a/web/components/workspace/issues-stats.tsx +++ b/web/components/workspace/issues-stats.tsx @@ -23,7 +23,10 @@ export const IssuesStats: React.FC = ({ data }) => {

Issues assigned to you

{data ? ( -
router.push(`/${workspaceSlug}/me/my-issues`)}> +
router.push(`/${workspaceSlug}/workspace-views/assigned`)} + > {data.assigned_issues_count}
) : ( diff --git a/web/components/workspace/settings/members-list-item.tsx b/web/components/workspace/settings/members-list-item.tsx index d98846e74..7c00f4f12 100644 --- a/web/components/workspace/settings/members-list-item.tsx +++ b/web/components/workspace/settings/members-list-item.tsx @@ -11,7 +11,7 @@ import { ConfirmWorkspaceMemberRemove } from "components/workspace"; // ui import { CustomSelect, Tooltip } from "@plane/ui"; // icons -import { ChevronDown, XCircle } from "lucide-react"; +import { ChevronDown, Dot, XCircle } from "lucide-react"; // constants import { ROLE } from "constants/workspace"; import { TUserWorkspaceRole } from "types"; @@ -132,7 +132,15 @@ export const WorkspaceMembersListItem: FC = (props) => { ) : (

{member.display_name || member.email}

)} -

{member.email ?? member.display_name}

+
+

{member.display_name}

+ {isAdmin && ( + <> + +

{member.email}

+ + )} +
diff --git a/web/components/workspace/settings/members-list.tsx b/web/components/workspace/settings/members-list.tsx index b4f263c49..2244c5cad 100644 --- a/web/components/workspace/settings/members-list.tsx +++ b/web/components/workspace/settings/members-list.tsx @@ -56,7 +56,7 @@ export const WorkspaceMembersList: FC<{ searchQuery: string }> = observer(({ sea ); return ( -
+
{workspaceMembersWithInvitations.length > 0 ? searchedMembers?.map((member) => ) : null} diff --git a/web/constants/issue.ts b/web/constants/issue.ts index d80430d0f..7979672fd 100644 --- a/web/constants/issue.ts +++ b/web/constants/issue.ts @@ -239,7 +239,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { filters: ["priority", "state_group", "labels", "start_date", "target_date"], display_properties: true, display_filters: { - group_by: ["state_detail.group", "priority", "project", "labels", null], + group_by: ["state_detail.group", "priority", "project", "labels"], order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], type: [null, "active", "backlog"], }, @@ -282,7 +282,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { filters: ["priority", "state_group", "labels", "start_date", "target_date"], display_properties: true, display_filters: { - group_by: ["state_detail.group", "priority", "project", "labels", null], + group_by: ["state_detail.group", "priority", "project", "labels"], order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], type: [null, "active", "backlog"], }, diff --git a/web/pages/[workspaceSlug]/me/profile/index.tsx b/web/pages/[workspaceSlug]/me/profile/index.tsx index 33813fa52..a6ae7c784 100644 --- a/web/pages/[workspaceSlug]/me/profile/index.tsx +++ b/web/pages/[workspaceSlug]/me/profile/index.tsx @@ -303,7 +303,8 @@ const ProfilePage: NextPageWithLayout = () => { value={value} onChange={onChange} label={value ? value.toString() : "Select your role"} - buttonClassName={errors.role ? "border-red-500 bg-red-500/10" : ""} + buttonClassName={errors.role ? "border-red-500 bg-red-500/10" : "border-none"} + className="rounded-md border !border-custom-border-200" width="w-full" input > @@ -369,6 +370,8 @@ const ProfilePage: NextPageWithLayout = () => { options={timeZoneOptions} onChange={onChange} optionsClassName="w-full" + buttonClassName={"border-none"} + className="rounded-md border !border-custom-border-200" input /> )} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index 6906243ca..778b0d94f 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -1,12 +1,13 @@ import React, { useEffect, useRef, useState, ReactElement } from "react"; import { useRouter } from "next/router"; -import useSWR, { mutate } from "swr"; +import useSWR from "swr"; import { Controller, useForm } from "react-hook-form"; // services import { PageService } from "services/page.service"; -import { useDebouncedCallback } from "use-debounce"; +import { FileService } from "services/file.service"; // hooks import useUser from "hooks/use-user"; +import { useDebouncedCallback } from "use-debounce"; // layouts import { AppLayout } from "layouts/app-layout"; // components @@ -24,7 +25,6 @@ import { NextPageWithLayout } from "types/app"; import { IPage } from "types"; // fetch-keys import { PAGE_DETAILS } from "constants/fetch-keys"; -import { FileService } from "services/file.service"; // services const fileService = new FileService(); @@ -45,10 +45,14 @@ const PageDetailsPage: NextPageWithLayout = () => { }); // =================== Fetching Page Details ====================== - const { data: pageDetails, error } = useSWR( - workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId as string) : null, - workspaceSlug && projectId - ? () => pageService.getPageDetails(workspaceSlug as string, projectId as string, pageId as string) + const { + data: pageDetails, + mutate: mutatePageDetails, + error, + } = useSWR( + workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId.toString()) : null, + workspaceSlug && projectId && pageId + ? () => pageService.getPageDetails(workspaceSlug.toString(), projectId.toString(), pageId.toString()) : null ); @@ -57,20 +61,23 @@ const PageDetailsPage: NextPageWithLayout = () => { if (!formData.name || formData.name.length === 0 || formData.name === "") return; - await pageService.patchPage(workspaceSlug as string, projectId as string, pageId as string, formData).then(() => { - mutate( - PAGE_DETAILS(pageId as string), - (prevData) => ({ - ...prevData, - ...formData, - }), - false - ); - }); + await pageService + .patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), formData) + .then(() => { + mutatePageDetails( + (prevData) => ({ + ...prevData, + ...formData, + }), + false + ); + }); }; const createPage = async (payload: Partial) => { - await pageService.createPage(workspaceSlug as string, projectId as string, payload); + if (!workspaceSlug || !projectId) return; + + await pageService.createPage(workspaceSlug.toString(), projectId.toString(), payload); }; // ================ Page Menu Actions ================== @@ -84,79 +91,79 @@ const PageDetailsPage: NextPageWithLayout = () => { }; const archivePage = async () => { + if (!workspaceSlug || !projectId || !pageId) return; + try { - await pageService.archivePage(workspaceSlug as string, projectId as string, pageId as string).then(() => { - mutate( - PAGE_DETAILS(pageId as string), - (prevData) => { - if (prevData && prevData.is_locked) { - prevData.archived_at = renderDateFormat(new Date()); - return prevData; - } - }, - true - ); - }); + mutatePageDetails((prevData) => { + if (!prevData) return; + + return { + ...prevData, + archived_at: renderDateFormat(new Date()), + }; + }, true); + + await pageService.archivePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()); } catch (e) { - console.log(e); + mutatePageDetails(); } }; const unArchivePage = async () => { + if (!workspaceSlug || !projectId || !pageId) return; + try { - await pageService.restorePage(workspaceSlug as string, projectId as string, pageId as string).then(() => { - mutate( - PAGE_DETAILS(pageId as string), - (prevData) => { - if (prevData && prevData.is_locked) { - prevData.archived_at = null; - return prevData; - } - }, - true - ); - }); + mutatePageDetails((prevData) => { + if (!prevData) return; + + return { + ...prevData, + archived_at: null, + }; + }, false); + + await pageService.restorePage(workspaceSlug.toString(), projectId.toString(), pageId.toString()); } catch (e) { - console.log(e); + mutatePageDetails(); } }; // ========================= Page Lock ========================== const lockPage = async () => { + if (!workspaceSlug || !projectId || !pageId) return; + try { - await pageService.lockPage(workspaceSlug as string, projectId as string, pageId as string).then(() => { - mutate( - PAGE_DETAILS(pageId as string), - (prevData) => { - if (prevData && prevData.is_locked) { - prevData.is_locked = true; - } - return prevData; - }, - true - ); - }); + mutatePageDetails((prevData) => { + if (!prevData) return; + + return { + ...prevData, + is_locked: true, + }; + }, false); + + await pageService.lockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()); } catch (e) { - console.log(e); + mutatePageDetails(); } }; const unlockPage = async () => { + if (!workspaceSlug || !projectId || !pageId) return; + try { - await pageService.unlockPage(workspaceSlug as string, projectId as string, pageId as string).then(() => { - mutate( - PAGE_DETAILS(pageId as string), - (prevData) => { - if (prevData && prevData.is_locked) { - prevData.is_locked = false; - return prevData; - } - }, - true - ); - }); + mutatePageDetails((prevData) => { + if (!prevData) return; + + return { + ...prevData, + is_locked: false, + }; + }, false); + + await pageService.unlockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString()); } catch (e) { - console.log(e); + mutatePageDetails(); } }; @@ -185,8 +192,8 @@ const PageDetailsPage: NextPageWithLayout = () => { }} /> ) : pageDetails ? ( -
-
+
+
{pageDetails.is_locked || pageDetails.archived_at ? ( { debouncedUpdatesEnabled={false} setIsSubmitting={setIsSubmitting} value={!value || value === "" ? "

" : value} - customClassName="tracking-tight self-center w-full max-w-full px-0" + customClassName="tracking-tight self-center px-0 h-full w-full" onChange={(_description_json: Object, description_html: string) => { onChange(description_html); setIsSubmitting("submitting"); diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx index ce69f2c5e..edc2ef7d9 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx @@ -90,7 +90,7 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => { projectId={projectId.toString()} /> )} -
+

Pages

diff --git a/web/pages/[workspaceSlug]/settings/webhooks/index.tsx b/web/pages/[workspaceSlug]/settings/webhooks/index.tsx index ce650e81c..f311741b8 100644 --- a/web/pages/[workspaceSlug]/settings/webhooks/index.tsx +++ b/web/pages/[workspaceSlug]/settings/webhooks/index.tsx @@ -31,7 +31,7 @@ const WebhooksPage: NextPage = observer(() => { return ( }> -
+
{loader ? (
@@ -41,10 +41,8 @@ const WebhooksPage: NextPage = observer(() => { {Object.keys(webhooks).length > 0 ? ( ) : ( -
-
- -
+
+
)} diff --git a/web/public/empty-state/web-hook.svg b/web/public/empty-state/web-hook.svg new file mode 100644 index 000000000..f8e32d3e5 --- /dev/null +++ b/web/public/empty-state/web-hook.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/services/project/project.service.ts b/web/services/project/project.service.ts index e4f1149f2..5fb0e4a10 100644 --- a/web/services/project/project.service.ts +++ b/web/services/project/project.service.ts @@ -69,7 +69,7 @@ export class ProjectService extends APIService { } async joinProject(workspaceSlug: string, project_ids: string[]): Promise { - return this.post(`/api/workspaces/${workspaceSlug}/projects/join/`, { project_ids }) + return this.post(`/api/users/me/workspaces/${workspaceSlug}/projects/invitations/`, { project_ids }) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/web/store/page.store.ts b/web/store/page.store.ts index 44feaceb4..c86cd3814 100644 --- a/web/store/page.store.ts +++ b/web/store/page.store.ts @@ -86,39 +86,67 @@ export class PageStore implements IPageStore { } get projectPages() { - if (!this.rootStore.project.projectId) return; - return this.pages?.[this.rootStore.project.projectId] || []; + const projectId = this.rootStore.project.projectId; + + if (!projectId || !this.pages[projectId]) return undefined; + + return this.pages?.[projectId] || []; } get recentProjectPages() { - if (!this.rootStore.project.projectId) return; - const data: IRecentPages = { today: [], yesterday: [], this_week: [] }; - data["today"] = this.pages[this.rootStore.project.projectId]?.filter((p) => isToday(new Date(p.created_at))) || []; - data["yesterday"] = - this.pages[this.rootStore.project.projectId]?.filter((p) => isYesterday(new Date(p.created_at))) || []; + const projectId = this.rootStore.project.projectId; + + if (!projectId) return undefined; + + if (!this.pages[projectId]) return undefined; + + const data: IRecentPages = { today: [], yesterday: [], this_week: [], older: [] }; + + data["today"] = this.pages[projectId]?.filter((p) => isToday(new Date(p.created_at))) || []; + data["yesterday"] = this.pages[projectId]?.filter((p) => isYesterday(new Date(p.created_at))) || []; data["this_week"] = - this.pages[this.rootStore.project.projectId]?.filter((p) => isThisWeek(new Date(p.created_at))) || []; + this.pages[projectId]?.filter( + (p) => + isThisWeek(new Date(p.created_at)) && !isToday(new Date(p.created_at)) && !isYesterday(new Date(p.created_at)) + ) || []; + data["older"] = + this.pages[projectId]?.filter( + (p) => !isThisWeek(new Date(p.created_at)) && !isYesterday(new Date(p.created_at)) + ) || []; + return data; } get favoriteProjectPages() { - if (!this.rootStore.project.projectId) return; - return this.pages[this.rootStore.project.projectId]?.filter((p) => p.is_favorite); + const projectId = this.rootStore.project.projectId; + + if (!projectId || !this.pages[projectId]) return undefined; + + return this.pages[projectId]?.filter((p) => p.is_favorite); } get privateProjectPages() { - if (!this.rootStore.project.projectId) return; - return this.pages[this.rootStore.project.projectId]?.filter((p) => p.access === 1); + const projectId = this.rootStore.project.projectId; + + if (!projectId || !this.pages[projectId]) return undefined; + + return this.pages[projectId]?.filter((p) => p.access === 1); } get sharedProjectPages() { - if (!this.rootStore.project.projectId) return; - return this.pages[this.rootStore.project.projectId]?.filter((p) => p.access === 0); + const projectId = this.rootStore.project.projectId; + + if (!projectId || !this.pages[projectId]) return undefined; + + return this.pages[projectId]?.filter((p) => p.access === 0); } get archivedProjectPages() { - if (!this.rootStore.project.projectId) return; - return this.archivedPages[this.rootStore.project.projectId]; + const projectId = this.rootStore.project.projectId; + + if (!projectId || !this.archivedPages[projectId]) return undefined; + + return this.archivedPages[projectId]; } addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => { diff --git a/web/types/app.d.ts b/web/types/app.d.ts index d5a7953b1..05f0fc7e5 100644 --- a/web/types/app.d.ts +++ b/web/types/app.d.ts @@ -11,4 +11,6 @@ export interface IAppConfig { slack_client_id: string | null; posthog_api_key: string | null; posthog_host: string | null; + has_openai_configured: boolean; + has_unsplash_configured: boolean; } diff --git a/web/types/pages.d.ts b/web/types/pages.d.ts index c3c87f572..a1c241f6a 100644 --- a/web/types/pages.d.ts +++ b/web/types/pages.d.ts @@ -30,6 +30,7 @@ export interface IRecentPages { today: IPage[]; yesterday: IPage[]; this_week: IPage[]; + older: IPage[]; [key: string]: IPage[]; }