diff --git a/.env.example b/.env.example index 9fe0f47d9..082aa753b 100644 --- a/.env.example +++ b/.env.example @@ -1,38 +1,3 @@ -# Frontend -# Extra image domains that need to be added for Next Image -NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS= -# Google Client ID for Google OAuth -NEXT_PUBLIC_GOOGLE_CLIENTID="" -# Github ID for Github OAuth -NEXT_PUBLIC_GITHUB_ID="" -# Github App Name for GitHub Integration -NEXT_PUBLIC_GITHUB_APP_NAME="" -# Sentry DSN for error monitoring -NEXT_PUBLIC_SENTRY_DSN="" -# Enable/Disable OAUTH - default 0 for selfhosted instance -NEXT_PUBLIC_ENABLE_OAUTH=0 -# Enable/Disable sentry -NEXT_PUBLIC_ENABLE_SENTRY=0 -# Enable/Disable session recording -NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0 -# Enable/Disable event tracking -NEXT_PUBLIC_TRACK_EVENTS=0 -# Slack for Slack Integration -NEXT_PUBLIC_SLACK_CLIENT_ID="" -# For Telemetry, set it to "app.plane.so" -NEXT_PUBLIC_PLAUSIBLE_DOMAIN="" -# public boards deploy url -NEXT_PUBLIC_DEPLOY_URL="" -# plane deploy using nginx -NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 - -# Backend -# Debug value for api server use it as 0 for production use -DEBUG=0 - -# Error logs -SENTRY_DSN="" - # Database Settings PGUSER="plane" PGPASSWORD="plane" @@ -45,15 +10,6 @@ REDIS_HOST="plane-redis" REDIS_PORT="6379" REDIS_URL="redis://${REDIS_HOST}:6379/" -# Email Settings -EMAIL_HOST="" -EMAIL_HOST_USER="" -EMAIL_HOST_PASSWORD="" -EMAIL_PORT=587 -EMAIL_FROM="Team Plane " -EMAIL_USE_TLS="1" -EMAIL_USE_SSL="0" - # AWS Settings AWS_REGION="" AWS_ACCESS_KEY_ID="access-key" @@ -69,9 +25,6 @@ OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint OPENAI_API_KEY="sk-" # add your openai key here GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access -# Github -GITHUB_CLIENT_SECRET="" # For fetching release notes - # Settings related to Docker DOCKERIZED=1 # set to 1 If using the pre-configured minio setup @@ -80,10 +33,3 @@ USE_MINIO=1 # Nginx Configuration NGINX_PORT=80 -# Default Creds -DEFAULT_EMAIL="captain@plane.so" -DEFAULT_PASSWORD="password123" - -# SignUps -ENABLE_SIGNUP="1" -# Auto generated and Required that will be generated from setup.sh diff --git a/apiserver/.env.example b/apiserver/.env.example new file mode 100644 index 000000000..a2a214fe6 --- /dev/null +++ b/apiserver/.env.example @@ -0,0 +1,60 @@ +# Backend +# Debug value for api server use it as 0 for production use +DEBUG=0 + +# Error logs +SENTRY_DSN="" + +# Database Settings +PGUSER="plane" +PGPASSWORD="plane" +PGHOST="plane-db" +PGDATABASE="plane" +DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} + +# Redis Settings +REDIS_HOST="plane-redis" +REDIS_PORT="6379" +REDIS_URL="redis://${REDIS_HOST}:6379/" + +# Email Settings +EMAIL_HOST="" +EMAIL_HOST_USER="" +EMAIL_HOST_PASSWORD="" +EMAIL_PORT=587 +EMAIL_FROM="Team Plane " +EMAIL_USE_TLS="1" +EMAIL_USE_SSL="0" + +# AWS Settings +AWS_REGION="" +AWS_ACCESS_KEY_ID="access-key" +AWS_SECRET_ACCESS_KEY="secret-key" +AWS_S3_ENDPOINT_URL="http://plane-minio:9000" +# Changing this requires change in the nginx.conf for uploads if using minio setup +AWS_S3_BUCKET_NAME="uploads" +# Maximum file upload limit +FILE_SIZE_LIMIT=5242880 + +# GPT settings +OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint +OPENAI_API_KEY="sk-" # add your openai key here +GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access + +# Github +GITHUB_CLIENT_SECRET="" # For fetching release notes + +# Settings related to Docker +DOCKERIZED=1 +# set to 1 If using the pre-configured minio setup +USE_MINIO=1 + +# Nginx Configuration +NGINX_PORT=80 + +# Default Creds +DEFAULT_EMAIL="captain@plane.so" +DEFAULT_PASSWORD="password123" + +# SignUps +ENABLE_SIGNUP="1" diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 2dc910caf..610b527ca 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -31,8 +31,6 @@ from .issue import ( IssueActivitySerializer, IssueCommentSerializer, IssuePropertySerializer, - BlockerIssueSerializer, - BlockedIssueSerializer, IssueAssigneeSerializer, LabelSerializer, IssueSerializer, @@ -45,6 +43,8 @@ from .issue import ( IssueReactionSerializer, CommentReactionSerializer, IssueVoteSerializer, + IssueRelationSerializer, + RelatedIssueSerializer, IssuePublicSerializer, ) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 938c7cab4..5888b759c 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -17,12 +17,10 @@ from plane.db.models import ( IssueActivity, IssueComment, IssueProperty, - IssueBlocker, IssueAssignee, IssueSubscriber, IssueLabel, Label, - IssueBlocker, CycleIssue, Cycle, Module, @@ -32,6 +30,7 @@ from plane.db.models import ( IssueReaction, CommentReaction, IssueVote, + IssueRelation, ) @@ -81,25 +80,12 @@ class IssueCreateSerializer(BaseSerializer): required=False, ) - # List of issues that are blocking this issue - blockers_list = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()), - write_only=True, - required=False, - ) labels_list = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), write_only=True, required=False, ) - # List of issues that are blocked by this issue - blocks_list = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Issue.objects.all()), - write_only=True, - required=False, - ) - class Meta: model = Issue fields = "__all__" @@ -122,10 +108,8 @@ class IssueCreateSerializer(BaseSerializer): return data def create(self, validated_data): - blockers = validated_data.pop("blockers_list", None) assignees = validated_data.pop("assignees_list", None) labels = validated_data.pop("labels_list", None) - blocks = validated_data.pop("blocks_list", None) project_id = self.context["project_id"] workspace_id = self.context["workspace_id"] @@ -137,22 +121,6 @@ class IssueCreateSerializer(BaseSerializer): created_by_id = issue.created_by_id updated_by_id = issue.updated_by_id - if blockers is not None and len(blockers): - IssueBlocker.objects.bulk_create( - [ - IssueBlocker( - block=issue, - blocked_by=blocker, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for blocker in blockers - ], - batch_size=10, - ) - if assignees is not None and len(assignees): IssueAssignee.objects.bulk_create( [ @@ -196,29 +164,11 @@ class IssueCreateSerializer(BaseSerializer): batch_size=10, ) - if blocks is not None and len(blocks): - IssueBlocker.objects.bulk_create( - [ - IssueBlocker( - block=block, - blocked_by=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for block in blocks - ], - batch_size=10, - ) - return issue def update(self, instance, validated_data): - blockers = validated_data.pop("blockers_list", None) assignees = validated_data.pop("assignees_list", None) labels = validated_data.pop("labels_list", None) - blocks = validated_data.pop("blocks_list", None) # Related models project_id = instance.project_id @@ -226,23 +176,6 @@ class IssueCreateSerializer(BaseSerializer): created_by_id = instance.created_by_id updated_by_id = instance.updated_by_id - if blockers is not None: - IssueBlocker.objects.filter(block=instance).delete() - IssueBlocker.objects.bulk_create( - [ - IssueBlocker( - block=instance, - blocked_by=blocker, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for blocker in blockers - ], - batch_size=10, - ) - if assignees is not None: IssueAssignee.objects.filter(issue=instance).delete() IssueAssignee.objects.bulk_create( @@ -277,23 +210,6 @@ class IssueCreateSerializer(BaseSerializer): batch_size=10, ) - if blocks is not None: - IssueBlocker.objects.filter(blocked_by=instance).delete() - IssueBlocker.objects.bulk_create( - [ - IssueBlocker( - block=block, - blocked_by=instance, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for block in blocks - ], - batch_size=10, - ) - # Time updation occues even when other related models are updated instance.updated_at = timezone.now() return super().update(instance, validated_data) @@ -375,32 +291,39 @@ class IssueLabelSerializer(BaseSerializer): ] -class BlockedIssueSerializer(BaseSerializer): - blocked_issue_detail = IssueProjectLiteSerializer(source="block", read_only=True) +class IssueRelationSerializer(BaseSerializer): + related_issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") class Meta: - model = IssueBlocker + model = IssueRelation fields = [ - "blocked_issue_detail", - "blocked_by", - "block", + "related_issue_detail", + "relation_type", + "related_issue", + "issue", + "id" + ] + read_only_fields = [ + "workspace", + "project", ] - read_only_fields = fields - -class BlockerIssueSerializer(BaseSerializer): - blocker_issue_detail = IssueProjectLiteSerializer( - source="blocked_by", read_only=True - ) +class RelatedIssueSerializer(BaseSerializer): + issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue") class Meta: - model = IssueBlocker + model = IssueRelation fields = [ - "blocker_issue_detail", - "blocked_by", - "block", + "issue_detail", + "relation_type", + "related_issue", + "issue", + "id" + ] + read_only_fields = [ + "workspace", + "project", ] - read_only_fields = fields class IssueAssigneeSerializer(BaseSerializer): @@ -617,10 +540,8 @@ class IssueSerializer(BaseSerializer): parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") label_details = LabelSerializer(read_only=True, source="labels", many=True) assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - # List of issues blocked by this issue - blocked_issues = BlockedIssueSerializer(read_only=True, many=True) - # List of issues that block this issue - blocker_issues = BlockerIssueSerializer(read_only=True, many=True) + related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True) + issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True) issue_cycle = IssueCycleDetailSerializer(read_only=True) issue_module = IssueModuleDetailSerializer(read_only=True) issue_link = IssueLinkSerializer(read_only=True, many=True) diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 558b7f059..1d4a16eb6 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -90,7 +90,9 @@ from plane.api.views import ( IssueSubscriberViewSet, IssueCommentPublicViewSet, IssueReactionViewSet, + IssueRelationViewSet, CommentReactionViewSet, + IssueDraftViewSet, ## End Issues # States StateViewSet, @@ -1010,6 +1012,47 @@ urlpatterns = [ name="project-issue-archive", ), ## End Issue Archives + ## Issue Relation + path( + "workspaces//projects//issues//issue-relation/", + IssueRelationViewSet.as_view( + { + "post": "create", + } + ), + name="issue-relation", + ), + path( + "workspaces//projects//issues//issue-relation//", + IssueRelationViewSet.as_view( + { + "delete": "destroy", + } + ), + name="issue-relation", + ), + ## End Issue Relation + ## Issue Drafts + path( + "workspaces//projects//issue-drafts/", + IssueDraftViewSet.as_view( + { + "get": "list", + } + ), + name="project-issue-draft", + ), + path( + "workspaces//projects//issue-drafts//", + IssueDraftViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + name="project-issue-draft", + ), + ## End Issue Drafts ## File Assets path( "workspaces//file-assets/", diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 71647bfea..265ed9c90 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -86,8 +86,10 @@ from .issue import ( IssueReactionPublicViewSet, CommentReactionPublicViewSet, IssueVotePublicViewSet, + IssueRelationViewSet, IssueRetrievePublicEndpoint, ProjectIssuesPublicEndpoint, + IssueDraftViewSet, ) from .auth_extended import ( @@ -167,6 +169,4 @@ from .analytic import ( from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet -from .exporter import ( - ExportIssuesEndpoint, -) \ No newline at end of file +from .exporter import ExportIssuesEndpoint \ No newline at end of file diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 253da2c5b..4f9e1db32 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -517,6 +517,7 @@ class CycleIssueViewSet(BaseViewSet): try: order_by = request.GET.get("order_by", "created_at") group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) filters = issue_filters(request.query_params, "GET") issues = ( Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) @@ -555,9 +556,15 @@ class CycleIssueViewSet(BaseViewSet): issues_data = IssueStateSerializer(issues, many=True).data + if sub_group_by and sub_group_by == group_by: + return Response( + {"error": "Group by and sub group by cannot be same"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if group_by: return Response( - group_results(issues_data, group_by), + group_results(issues_data, group_by, sub_group_by), status=status.HTTP_200_OK, ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index cf4fa46d4..b6dcb88d5 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -24,6 +24,7 @@ from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.db import IntegrityError from django.conf import settings +from django.db import IntegrityError # Third Party imports from rest_framework.response import Response @@ -51,6 +52,7 @@ from plane.api.serializers import ( IssueReactionSerializer, CommentReactionSerializer, IssueVoteSerializer, + IssueRelationSerializer, IssuePublicSerializer, ) from plane.api.permissions import ( @@ -76,6 +78,7 @@ from plane.db.models import ( CommentReaction, ProjectDeployBoard, IssueVote, + IssueRelation, ProjectPublicMember, ) from plane.bgtasks.issue_activites_task import issue_activity @@ -266,9 +269,16 @@ class IssueViewSet(BaseViewSet): ## Grouping the results group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + if sub_group_by and sub_group_by == group_by: + return Response( + {"error": "Group by and sub group by cannot be same"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if group_by: return Response( - group_results(issues, group_by), status=status.HTTP_200_OK + group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK ) return Response(issues, status=status.HTTP_200_OK) @@ -443,9 +453,16 @@ class UserWorkSpaceIssues(BaseAPIView): ## Grouping the results group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + if sub_group_by and sub_group_by == group_by: + return Response( + {"error": "Group by and sub group by cannot be same"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if group_by: return Response( - group_results(issues, group_by), status=status.HTTP_200_OK + group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK ) return Response(issues, status=status.HTTP_200_OK) @@ -2040,6 +2057,98 @@ class IssueVotePublicViewSet(BaseViewSet): ) +class IssueRelationViewSet(BaseViewSet): + serializer_class = IssueRelationSerializer + model = IssueRelation + permission_classes = [ + ProjectEntityPermission, + ] + + def perform_destroy(self, instance): + current_instance = ( + self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() + ) + if current_instance is not None: + issue_activity.delay( + type="issue_relation.activity.deleted", + requested_data=json.dumps({"related_list": 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( + IssueRelationSerializer(current_instance).data, + cls=DjangoJSONEncoder, + ), + ) + return super().perform_destroy(instance) + + def create(self, request, slug, project_id, issue_id): + try: + related_list = request.data.get("related_list", []) + project = Project.objects.get(pk=project_id) + + issueRelation = IssueRelation.objects.bulk_create( + [ + IssueRelation( + issue_id=related_issue["issue"], + related_issue_id=related_issue["related_issue"], + relation_type=related_issue["relation_type"], + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for related_issue in related_list + ], + batch_size=10, + ignore_conflicts=True, + ) + + issue_activity.delay( + type="issue_relation.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + ) + + return Response( + IssueRelationSerializer(issueRelation, many=True).data, + status=status.HTTP_201_CREATED, + ) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"name": "The issue is already taken"}, + status=status.HTTP_410_GONE, + ) + else: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .select_related("issue") + .distinct() + ) class IssueRetrievePublicEndpoint(BaseAPIView): permission_classes = [ AllowAny, @@ -2240,3 +2349,157 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class IssueDraftViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + serializer_class = IssueFlatSerializer + model = Issue + + def get_queryset(self): + return ( + 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=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(is_draft=True) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + ) + + @method_decorator(gzip_page) + def list(self, request, slug, project_id): + try: + 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 = ( + self.get_queryset() + .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 + + ## Grouping the results + group_by = request.GET.get("group_by", False) + if group_by: + return Response( + group_results(issues, group_by), status=status.HTTP_200_OK + ) + + return Response(issues, status=status.HTTP_200_OK) + + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + + def retrieve(self, request, slug, project_id, pk=None): + try: + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True + ) + return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) + except Issue.DoesNotExist: + return Response( + {"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND + ) + diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 1cd741f84..5a472945a 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -308,6 +308,7 @@ class ModuleIssueViewSet(BaseViewSet): try: order_by = request.GET.get("order_by", "created_at") group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) filters = issue_filters(request.query_params, "GET") issues = ( Issue.issue_objects.filter(issue_module__module_id=module_id) @@ -346,9 +347,15 @@ class ModuleIssueViewSet(BaseViewSet): issues_data = IssueStateSerializer(issues, many=True).data + if sub_group_by and sub_group_by == group_by: + return Response( + {"error": "Group by and sub group by cannot be same"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if group_by: return Response( - group_results(issues_data, group_by), + group_results(issues_data, group_by, sub_group_by), status=status.HTTP_200_OK, ) diff --git a/apiserver/plane/api/views/search.py b/apiserver/plane/api/views/search.py index 0a8c5c530..35b75ce67 100644 --- a/apiserver/plane/api/views/search.py +++ b/apiserver/plane/api/views/search.py @@ -220,7 +220,7 @@ class IssueSearchEndpoint(BaseAPIView): query = request.query_params.get("search", False) workspace_search = request.query_params.get("workspace_search", "false") parent = request.query_params.get("parent", "false") - blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false") + issue_relation = request.query_params.get("issue_relation", "false") cycle = request.query_params.get("cycle", "false") module = request.query_params.get("module", "false") sub_issue = request.query_params.get("sub_issue", "false") @@ -247,12 +247,12 @@ class IssueSearchEndpoint(BaseAPIView): "parent_id", flat=True ) ) - if blocker_blocked_by == "true" and issue_id: + if issue_relation == "true" and issue_id: issue = Issue.issue_objects.get(pk=issue_id) issues = issues.filter( ~Q(pk=issue_id), - ~Q(blocked_issues__block=issue), - ~Q(blocker_issues__blocked_by=issue), + ~Q(issue_related__issue=issue), + ~Q(issue_relation__related_issue=issue), ) if sub_issue == "true" and issue_id: issue = Issue.issue_objects.get(pk=issue_id) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 0cadac553..2d13afc35 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -393,130 +393,6 @@ def track_assignees( ) -# Track changes in blocking issues -def track_blocks( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, -): - if len(requested_data.get("blocks_list")) > len( - current_instance.get("blocked_issues") - ): - for block in requested_data.get("blocks_list"): - if ( - len( - [ - blocked - for blocked in current_instance.get("blocked_issues") - if blocked.get("block") == block - ] - ) - == 0 - ): - issue = Issue.objects.get(pk=block) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value="", - new_value=f"{issue.project.identifier}-{issue.sequence_id}", - field="blocks", - project=project, - workspace=project.workspace, - comment=f"added blocking issue {project.identifier}-{issue.sequence_id}", - new_identifier=issue.id, - ) - ) - - # Blocked Issue Removal - if len(requested_data.get("blocks_list")) < len( - current_instance.get("blocked_issues") - ): - for blocked in current_instance.get("blocked_issues"): - if blocked.get("block") not in requested_data.get("blocks_list"): - issue = Issue.objects.get(pk=blocked.get("block")) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=f"{issue.project.identifier}-{issue.sequence_id}", - new_value="", - field="blocks", - project=project, - workspace=project.workspace, - comment=f"removed blocking issue {project.identifier}-{issue.sequence_id}", - old_identifier=issue.id, - ) - ) - - -# Track changes in blocked_by issues -def track_blockings( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, -): - if len(requested_data.get("blockers_list")) > len( - current_instance.get("blocker_issues") - ): - for block in requested_data.get("blockers_list"): - if ( - len( - [ - blocked - for blocked in current_instance.get("blocker_issues") - if blocked.get("blocked_by") == block - ] - ) - == 0 - ): - issue = Issue.objects.get(pk=block) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value="", - new_value=f"{issue.project.identifier}-{issue.sequence_id}", - field="blocking", - project=project, - workspace=project.workspace, - comment=f"added blocked by issue {project.identifier}-{issue.sequence_id}", - new_identifier=issue.id, - ) - ) - - # Blocked Issue Removal - if len(requested_data.get("blockers_list")) < len( - current_instance.get("blocker_issues") - ): - for blocked in current_instance.get("blocker_issues"): - if blocked.get("blocked_by") not in requested_data.get("blockers_list"): - issue = Issue.objects.get(pk=blocked.get("blocked_by")) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=f"{issue.project.identifier}-{issue.sequence_id}", - new_value="", - field="blocking", - project=project, - workspace=project.workspace, - comment=f"removed blocked by issue {project.identifier}-{issue.sequence_id}", - old_identifier=issue.id, - ) - ) - - def create_issue_activity( requested_data, current_instance, issue_id, project, actor, issue_activities ): @@ -637,8 +513,6 @@ def update_issue_activity( "start_date": track_start_date, "labels_list": track_labels, "assignees_list": track_assignees, - "blocks_list": track_blocks, - "blockers_list": track_blockings, "estimate_point": track_estimate_points, "archived_at": track_archive_at, "closed_to": track_closed_to, @@ -1170,6 +1044,57 @@ def delete_issue_vote_activity( ) +def create_issue_relation_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 + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + if current_instance is None and requested_data.get("related_list") is not None: + for issue_relation in requested_data.get("related_list"): + issue = Issue.objects.get(pk=issue_relation.get("related_issue")) + issue_activities.append( + IssueActivity( + issue_id=issue_relation.get("issue"), + actor=actor, + verb="created", + old_value="", + new_value=f"{project.identifier}-{issue.sequence_id}", + field=f'{issue_relation.get("relation_type")}', + project=project, + workspace=project.workspace, + comment=f'added {issue_relation.get("relation_type")} relation', + old_identifier=issue_relation.get("issue"), + ) + ) + + +def delete_issue_relation_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 + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + if current_instance is not None and requested_data.get("related_list") is None: + issue = Issue.objects.get(pk=current_instance.get("issue")) + issue_activities.append( + IssueActivity( + issue_id=current_instance.get("issue"), + actor=actor, + verb="deleted", + old_value=f"{project.identifier}-{issue.sequence_id}", + new_value="", + field=f'{current_instance.get("relation_type")}', + project=project, + workspace=project.workspace, + comment=f'deleted the {current_instance.get("relation_type")} relation', + old_identifier=current_instance.get("issue"), + ) + ) + + # Receive message from room group @shared_task def issue_activity( @@ -1233,6 +1158,8 @@ def issue_activity( "link.activity.deleted": delete_link_activity, "attachment.activity.created": create_attachment_activity, "attachment.activity.deleted": delete_attachment_activity, + "issue_relation.activity.created": create_issue_relation_activity, + "issue_relation.activity.deleted": delete_issue_relation_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, diff --git a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py new file mode 100644 index 000000000..950189c55 --- /dev/null +++ b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py @@ -0,0 +1,84 @@ +# Generated by Django 4.2.3 on 2023-09-12 07:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +from plane.db.models import IssueRelation +from sentry_sdk import capture_exception +import uuid + + +def create_issue_relation(apps, schema_editor): + try: + IssueBlockerModel = apps.get_model("db", "IssueBlocker") + updated_issue_relation = [] + for blocked_issue in IssueBlockerModel.objects.all(): + updated_issue_relation.append( + IssueRelation( + issue_id=blocked_issue.block_id, + related_issue_id=blocked_issue.blocked_by_id, + relation_type="blocked_by", + project_id=blocked_issue.project_id, + workspace_id=blocked_issue.workspace_id, + created_by_id=blocked_issue.created_by_id, + updated_by_id=blocked_issue.updated_by_id, + ) + ) + IssueRelation.objects.bulk_create(updated_issue_relation, batch_size=100) + except Exception as e: + print(e) + capture_exception(e) + + +def update_issue_priority_choice(apps, schema_editor): + IssueModel = apps.get_model("db", "Issue") + updated_issues = [] + for obj in IssueModel.objects.all(): + if obj.priority is None: + obj.priority = "none" + updated_issues.append(obj) + IssueModel.objects.bulk_update(updated_issues, ["priority"], batch_size=100) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0042_alter_analyticview_created_by_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='IssueRelation', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('relation_type', models.CharField(choices=[('duplicate', 'Duplicate'), ('relates_to', 'Relates To'), ('blocked_by', 'Blocked By')], default='blocked_by', max_length=20, verbose_name='Issue Relation Type')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_relation', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), + ('related_issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_related', to='db.issue')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Relation', + 'verbose_name_plural': 'Issue Relations', + 'db_table': 'issue_relations', + 'ordering': ('-created_at',), + 'unique_together': {('issue', 'related_issue')}, + }, + ), + migrations.AddField( + model_name='issue', + name='is_draft', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='issue', + name='priority', + field=models.CharField(choices=[('urgent', 'Urgent'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')], default='none', max_length=30, verbose_name='Issue Priority'), + ), + migrations.RunPython(create_issue_relation), + migrations.RunPython(update_issue_priority_choice), + ] diff --git a/apiserver/plane/db/migrations/0044_auto_20230913_0709.py b/apiserver/plane/db/migrations/0044_auto_20230913_0709.py new file mode 100644 index 000000000..f30062371 --- /dev/null +++ b/apiserver/plane/db/migrations/0044_auto_20230913_0709.py @@ -0,0 +1,138 @@ +# Generated by Django 4.2.3 on 2023-09-13 07:09 + +from django.db import migrations + + +def workspace_member_props(old_props): + new_props = { + "filters": { + "priority": old_props.get("filters", {}).get("priority", None), + "state": old_props.get("filters", {}).get("state", None), + "state_group": old_props.get("filters", {}).get("state_group", None), + "assignees": old_props.get("filters", {}).get("assignees", None), + "created_by": old_props.get("filters", {}).get("created_by", None), + "labels": old_props.get("filters", {}).get("labels", None), + "start_date": old_props.get("filters", {}).get("start_date", None), + "target_date": old_props.get("filters", {}).get("target_date", None), + "subscriber": old_props.get("filters", {}).get("subscriber", None), + }, + "display_filters": { + "group_by": old_props.get("groupByProperty", None), + "order_by": old_props.get("orderBy", "-created_at"), + "type": old_props.get("filters", {}).get("type", None), + "sub_issue": old_props.get("showSubIssues", True), + "show_empty_groups": old_props.get("showEmptyGroups", True), + "layout": old_props.get("issueView", "list"), + "calendar_date_range": old_props.get("calendarDateRange", ""), + }, + "display_properties": { + "assignee": old_props.get("properties", {}).get("assignee",None), + "attachment_count": old_props.get("properties", {}).get("attachment_count", None), + "created_on": old_props.get("properties", {}).get("created_on", None), + "due_date": old_props.get("properties", {}).get("due_date", None), + "estimate": old_props.get("properties", {}).get("estimate", None), + "key": old_props.get("properties", {}).get("key", None), + "labels": old_props.get("properties", {}).get("labels", None), + "link": old_props.get("properties", {}).get("link", None), + "priority": old_props.get("properties", {}).get("priority", None), + "start_date": old_props.get("properties", {}).get("start_date", None), + "state": old_props.get("properties", {}).get("state", None), + "sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", None), + "updated_on": old_props.get("properties", {}).get("updated_on", None), + }, + } + return new_props + + +def project_member_props(old_props): + new_props = { + "filters": { + "priority": old_props.get("filters", {}).get("priority", None), + "state": old_props.get("filters", {}).get("state", None), + "state_group": old_props.get("filters", {}).get("state_group", None), + "assignees": old_props.get("filters", {}).get("assignees", None), + "created_by": old_props.get("filters", {}).get("created_by", None), + "labels": old_props.get("filters", {}).get("labels", None), + "start_date": old_props.get("filters", {}).get("start_date", None), + "target_date": old_props.get("filters", {}).get("target_date", None), + "subscriber": old_props.get("filters", {}).get("subscriber", None), + }, + "display_filters": { + "group_by": old_props.get("groupByProperty", None), + "order_by": old_props.get("orderBy", "-created_at"), + "type": old_props.get("filters", {}).get("type", None), + "sub_issue": old_props.get("showSubIssues", True), + "show_empty_groups": old_props.get("showEmptyGroups", True), + "layout": old_props.get("issueView", "list"), + "calendar_date_range": old_props.get("calendarDateRange", ""), + }, + } + return new_props + + +def cycle_module_props(old_props): + new_props = { + "filters": { + "priority": old_props.get("filters", {}).get("priority", None), + "state": old_props.get("filters", {}).get("state", None), + "state_group": old_props.get("filters", {}).get("state_group", None), + "assignees": old_props.get("filters", {}).get("assignees", None), + "created_by": old_props.get("filters", {}).get("created_by", None), + "labels": old_props.get("filters", {}).get("labels", None), + "start_date": old_props.get("filters", {}).get("start_date", None), + "target_date": old_props.get("filters", {}).get("target_date", None), + "subscriber": old_props.get("filters", {}).get("subscriber", None), + }, + } + return new_props + + +def update_workspace_member_view_props(apps, schema_editor): + WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember") + updated_workspace_member = [] + for obj in WorkspaceMemberModel.objects.all(): + obj.view_props = workspace_member_props(obj.view_props) + obj.default_props = workspace_member_props(obj.default_props) + updated_workspace_member.append(obj) + WorkspaceMemberModel.objects.bulk_update(updated_workspace_member, ["view_props", "default_props"], batch_size=100) + +def update_project_member_view_props(apps, schema_editor): + ProjectMemberModel = apps.get_model("db", "ProjectMember") + updated_project_member = [] + for obj in ProjectMemberModel.objects.all(): + obj.view_props = project_member_props(obj.view_props) + obj.default_props = project_member_props(obj.default_props) + updated_project_member.append(obj) + ProjectMemberModel.objects.bulk_update(updated_project_member, ["view_props", "default_props"], batch_size=100) + +def update_cycle_props(apps, schema_editor): + CycleModel = apps.get_model("db", "Cycle") + updated_cycle = [] + for obj in CycleModel.objects.all(): + if "filter" in obj.view_props: + obj.view_props = cycle_module_props(obj.view_props) + updated_cycle.append(obj) + CycleModel.objects.bulk_update(updated_cycle, ["view_props"], batch_size=100) + +def update_module_props(apps, schema_editor): + ModuleModel = apps.get_model("db", "Module") + updated_module = [] + for obj in ModuleModel.objects.all(): + if "filter" in obj.view_props: + obj.view_props = cycle_module_props(obj.view_props) + updated_module.append(obj) + ModuleModel.objects.bulk_update(updated_module, ["view_props"], batch_size=100) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0043_alter_analyticview_created_by_and_more'), + ] + + operations = [ + migrations.RunPython(update_workspace_member_view_props), + migrations.RunPython(update_project_member_view_props), + migrations.RunPython(update_cycle_props), + migrations.RunPython(update_module_props), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 90532dc64..f60f7ac81 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -32,6 +32,7 @@ from .issue import ( IssueAssignee, Label, IssueBlocker, + IssueRelation, IssueLink, IssueSequence, IssueAttachment, diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index dd16cd963..65f1bc965 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -29,6 +29,7 @@ class IssueManager(models.Manager): | models.Q(issue_inbox__isnull=True) ) .exclude(archived_at__isnull=False) + .exclude(is_draft=True) ) @@ -83,6 +84,7 @@ class Issue(ProjectBaseModel): sort_order = models.FloatField(default=65535) completed_at = models.DateTimeField(null=True) archived_at = models.DateField(null=True) + is_draft = models.BooleanField(default=False) objects = models.Manager() issue_objects = IssueManager() @@ -178,6 +180,37 @@ class IssueBlocker(ProjectBaseModel): return f"{self.block.name} {self.blocked_by.name}" +class IssueRelation(ProjectBaseModel): + RELATION_CHOICES = ( + ("duplicate", "Duplicate"), + ("relates_to", "Relates To"), + ("blocked_by", "Blocked By"), + ) + + issue = models.ForeignKey( + Issue, related_name="issue_relation", on_delete=models.CASCADE + ) + related_issue = models.ForeignKey( + Issue, related_name="issue_related", on_delete=models.CASCADE + ) + relation_type = models.CharField( + max_length=20, + choices=RELATION_CHOICES, + verbose_name="Issue Relation Type", + default="blocked_by", + ) + + class Meta: + unique_together = ["issue", "related_issue"] + verbose_name = "Issue Relation" + verbose_name_plural = "Issue Relations" + db_table = "issue_relations" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.related_issue.name}" + + class IssueAssignee(ProjectBaseModel): issue = models.ForeignKey( Issue, on_delete=models.CASCADE, related_name="issue_assignee" diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index da155af40..4cd2134ac 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -25,13 +25,26 @@ ROLE_CHOICES = ( def get_default_props(): return { - "filters": {"type": None}, - "orderBy": "-created_at", - "collapsed": True, - "issueView": "list", - "filterIssue": None, - "groupByProperty": None, - "showEmptyGroups": True, + "filters": { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + }, + "display_filters": { + "group_by": None, + "order_by": '-created_at', + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + }, } diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 48d8c9f2d..c85268435 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -16,26 +16,41 @@ ROLE_CHOICES = ( def get_default_props(): return { - "filters": {"type": None}, - "groupByProperty": None, - "issueView": "list", - "orderBy": "-created_at", - "properties": { + "filters": { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + }, + "display_filters": { + "group_by": None, + "order_by": '-created_at', + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + }, + "display_properties": { "assignee": True, + "attachment_count": True, + "created_on": True, "due_date": True, + "estimate": True, "key": True, "labels": True, + "link": True, "priority": True, + "start_date": True, "state": True, "sub_issue_count": True, - "attachment_count": True, - "link": True, - "estimate": True, - "created_on": True, "updated_on": True, - "start_date": True, - }, - "showEmptyGroups": True, + } } diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index 535bf6eba..70762e7b4 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -15,7 +15,7 @@ def resolve_keys(group_keys, value): return value -def group_results(results_data, group_by): +def group_results(results_data, group_by, sub_group_by=False): """group results data into certain group_by Args: @@ -25,38 +25,64 @@ def group_results(results_data, group_by): Returns: obj: grouped results """ - response_dict = dict() + if sub_group_by: + main_responsive_dict = dict() - if group_by == "priority": - response_dict = { - "urgent": [], - "high": [], - "medium": [], - "low": [], - "None": [], - } + if sub_group_by == "priority": + main_responsive_dict = { + "urgent": {}, + "high": {}, + "medium": {}, + "low": {}, + "none": {}, + } - for value in results_data: - group_attribute = resolve_keys(group_by, value) - if isinstance(group_attribute, list): - if len(group_attribute): - for attrib in group_attribute: - if str(attrib) in response_dict: - response_dict[str(attrib)].append(value) - else: - response_dict[str(attrib)] = [] - response_dict[str(attrib)].append(value) + for value in results_data: + main_group_attribute = resolve_keys(sub_group_by, value) + if str(main_group_attribute) not in main_responsive_dict: + main_responsive_dict[str(main_group_attribute)] = {} + group_attribute = resolve_keys(group_by, value) + if str(group_attribute) in main_responsive_dict: + main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value) else: - if str(None) in response_dict: - response_dict[str(None)].append(value) + main_responsive_dict[str(main_group_attribute)][str(group_attribute)] = [] + main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value) + + return main_responsive_dict + + else: + response_dict = dict() + + if group_by == "priority": + response_dict = { + "urgent": [], + "high": [], + "medium": [], + "low": [], + "none": [], + } + + for value in results_data: + group_attribute = resolve_keys(group_by, value) + if isinstance(group_attribute, list): + if len(group_attribute): + for attrib in group_attribute: + if str(attrib) in response_dict: + response_dict[str(attrib)].append(value) + else: + response_dict[str(attrib)] = [] + response_dict[str(attrib)].append(value) else: - response_dict[str(None)] = [] - response_dict[str(None)].append(value) - else: - if str(group_attribute) in response_dict: - response_dict[str(group_attribute)].append(value) + if str(None) in response_dict: + response_dict[str(None)].append(value) + else: + response_dict[str(None)] = [] + response_dict[str(None)].append(value) else: - response_dict[str(group_attribute)] = [] - response_dict[str(group_attribute)].append(value) + if str(group_attribute) in response_dict: + response_dict[str(group_attribute)].append(value) + else: + response_dict[str(group_attribute)] = [] + response_dict[str(group_attribute)].append(value) - return response_dict + return response_dict diff --git a/docker-compose.yml b/docker-compose.yml index cf631face..e3c1b37be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,37 +1,5 @@ version: "3.8" -x-api-and-worker-env: &api-and-worker-env - DEBUG: ${DEBUG} - SENTRY_DSN: ${SENTRY_DSN} - DJANGO_SETTINGS_MODULE: plane.settings.production - DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE} - REDIS_URL: redis://plane-redis:6379/ - EMAIL_HOST: ${EMAIL_HOST} - EMAIL_HOST_USER: ${EMAIL_HOST_USER} - EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD} - EMAIL_PORT: ${EMAIL_PORT} - EMAIL_FROM: ${EMAIL_FROM} - EMAIL_USE_TLS: ${EMAIL_USE_TLS} - EMAIL_USE_SSL: ${EMAIL_USE_SSL} - AWS_REGION: ${AWS_REGION} - AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} - AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME} - AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL} - FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT} - WEB_URL: ${WEB_URL} - GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} - DISABLE_COLLECTSTATIC: 1 - DOCKERIZED: 1 - OPENAI_API_BASE: ${OPENAI_API_BASE} - OPENAI_API_KEY: ${OPENAI_API_KEY} - GPT_ENGINE: ${GPT_ENGINE} - SECRET_KEY: ${SECRET_KEY} - DEFAULT_EMAIL: ${DEFAULT_EMAIL} - DEFAULT_PASSWORD: ${DEFAULT_PASSWORD} - USE_MINIO: ${USE_MINIO} - ENABLE_SIGNUP: ${ENABLE_SIGNUP} - services: plane-web: container_name: planefrontend @@ -40,23 +8,8 @@ services: dockerfile: ./web/Dockerfile.web args: DOCKER_BUILDKIT: 1 - NEXT_PUBLIC_API_BASE_URL: http://localhost:8000 - NEXT_PUBLIC_DEPLOY_URL: http://localhost/spaces restart: always command: /usr/local/bin/start.sh web/server.js web - env_file: - - .env - environment: - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL} - NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL} - NEXT_PUBLIC_GOOGLE_CLIENTID: "0" - NEXT_PUBLIC_GITHUB_APP_NAME: "0" - NEXT_PUBLIC_GITHUB_ID: "0" - NEXT_PUBLIC_SENTRY_DSN: "0" - NEXT_PUBLIC_ENABLE_OAUTH: "0" - NEXT_PUBLIC_ENABLE_SENTRY: "0" - NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0" - NEXT_PUBLIC_TRACK_EVENTS: "0" depends_on: - plane-api - plane-worker @@ -68,14 +21,8 @@ services: dockerfile: ./space/Dockerfile.space args: DOCKER_BUILDKIT: 1 - NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1 - NEXT_PUBLIC_API_BASE_URL: http://localhost:8000 restart: always command: /usr/local/bin/start.sh space/server.js space - env_file: - - .env - environment: - - NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} depends_on: - plane-api - plane-worker @@ -91,9 +38,7 @@ services: restart: always command: ./bin/takeoff env_file: - - .env - environment: - <<: *api-and-worker-env + - ./apiserver/.env depends_on: - plane-db - plane-redis @@ -108,9 +53,7 @@ services: restart: always command: ./bin/worker env_file: - - .env - environment: - <<: *api-and-worker-env + - ./apiserver/.env depends_on: - plane-api - plane-db @@ -126,9 +69,7 @@ services: restart: always command: ./bin/beat env_file: - - .env - environment: - <<: *api-and-worker-env + - ./apiserver/.env depends_on: - plane-api - plane-db @@ -163,8 +104,6 @@ services: command: server /export --console-address ":9090" volumes: - uploads:/export - env_file: - - .env environment: MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} @@ -187,8 +126,6 @@ services: restart: always ports: - ${NGINX_PORT}:80 - env_file: - - .env environment: FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template index 974f4907d..36a68fa55 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -1,30 +1,29 @@ events { } - http { - sendfile on; + sendfile on; -server { - listen 80; - root /www/data/; - access_log /var/log/nginx/access.log; + server { + listen 80; + root /www/data/; + access_log /var/log/nginx/access.log; - client_max_body_size ${FILE_SIZE_LIMIT}; + client_max_body_size ${FILE_SIZE_LIMIT}; - location / { - proxy_pass http://planefrontend:3000/; + location / { + proxy_pass http://planefrontend:3000/; + } + + location /api/ { + proxy_pass http://planebackend:8000/api/; + } + + location /spaces/ { + proxy_pass http://planedeploy:3000/spaces/; + } + + location /${BUCKET_NAME}/ { + proxy_pass http://plane-minio:9000/uploads/; + } } - - location /api/ { - proxy_pass http://planebackend:8000/api/; - } - - location /spaces/ { - proxy_pass http://planedeploy:3000/spaces/; - } - - location /${BUCKET_NAME}/ { - proxy_pass http://plane-minio:9000/uploads/; - } -} } \ No newline at end of file diff --git a/package.json b/package.json index 793a1922f..de09c6ee9 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,12 @@ "format": "prettier --write \"**/*.{ts,tsx,md}\"" }, "devDependencies": { + "autoprefixer": "^10.4.15", "eslint-config-custom": "*", + "postcss": "^8.4.29", "prettier": "latest", + "prettier-plugin-tailwindcss": "^0.5.4", + "tailwindcss": "^3.3.3", "turbo": "latest" }, "packageManager": "yarn@1.22.19" diff --git a/packages/eslint-config-custom/index.js b/packages/eslint-config-custom/index.js index d31a76406..82be65376 100644 --- a/packages/eslint-config-custom/index.js +++ b/packages/eslint-config-custom/index.js @@ -16,5 +16,7 @@ module.exports = { "no-duplicate-imports": "error", "arrow-body-style": ["error", "as-needed"], "react/self-closing-comp": ["error", { component: true, html: true }], + "@next/next/no-img-element": "off", + "@typescript-eslint/no-unused-vars": ["warn"], }, }; diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json new file mode 100644 index 000000000..1bd5a0e1c --- /dev/null +++ b/packages/tailwind-config-custom/package.json @@ -0,0 +1,10 @@ +{ + "name": "tailwind-config-custom", + "version": "0.0.1", + "description": "common tailwind configuration across monorepo", + "main": "index.js", + "devDependencies": { + "@tailwindcss/typography": "^0.5.10", + "tailwindcss-animate": "^1.0.7" + } +} diff --git a/packages/tailwind-config-custom/postcss.config.js b/packages/tailwind-config-custom/postcss.config.js new file mode 100644 index 000000000..cbfea5ea2 --- /dev/null +++ b/packages/tailwind-config-custom/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: { + "tailwindcss/nesting": {}, + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js new file mode 100644 index 000000000..061168c4f --- /dev/null +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -0,0 +1,212 @@ +const convertToRGB = (variableName) => `rgba(var(${variableName}))`; + +module.exports = { + darkMode: "class", + content: [ + "./components/**/*.tsx", + "./constants/**/*.{js,ts,jsx,tsx}", + "./layouts/**/*.tsx", + "./pages/**/*.tsx", + "./ui/**/*.tsx", + ], + 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: { + 0: "rgb(255, 255, 255)", + 10: convertToRGB("--color-primary-10"), + 20: convertToRGB("--color-primary-20"), + 30: convertToRGB("--color-primary-30"), + 40: convertToRGB("--color-primary-40"), + 50: convertToRGB("--color-primary-50"), + 60: convertToRGB("--color-primary-60"), + 70: convertToRGB("--color-primary-70"), + 80: convertToRGB("--color-primary-80"), + 90: convertToRGB("--color-primary-90"), + 100: convertToRGB("--color-primary-100"), + 200: convertToRGB("--color-primary-200"), + 300: convertToRGB("--color-primary-300"), + 400: convertToRGB("--color-primary-400"), + 500: convertToRGB("--color-primary-500"), + 600: convertToRGB("--color-primary-600"), + 700: convertToRGB("--color-primary-700"), + 800: convertToRGB("--color-primary-800"), + 900: convertToRGB("--color-primary-900"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-primary-100"), + }, + background: { + 0: "rgb(255, 255, 255)", + 10: convertToRGB("--color-background-10"), + 20: convertToRGB("--color-background-20"), + 30: convertToRGB("--color-background-30"), + 40: convertToRGB("--color-background-40"), + 50: convertToRGB("--color-background-50"), + 60: convertToRGB("--color-background-60"), + 70: convertToRGB("--color-background-70"), + 80: convertToRGB("--color-background-80"), + 90: convertToRGB("--color-background-90"), + 100: convertToRGB("--color-background-100"), + 200: convertToRGB("--color-background-200"), + 300: convertToRGB("--color-background-300"), + 400: convertToRGB("--color-background-400"), + 500: convertToRGB("--color-background-500"), + 600: convertToRGB("--color-background-600"), + 700: convertToRGB("--color-background-700"), + 800: convertToRGB("--color-background-800"), + 900: convertToRGB("--color-background-900"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-background-100"), + }, + text: { + 0: "rgb(255, 255, 255)", + 10: convertToRGB("--color-text-10"), + 20: convertToRGB("--color-text-20"), + 30: convertToRGB("--color-text-30"), + 40: convertToRGB("--color-text-40"), + 50: convertToRGB("--color-text-50"), + 60: convertToRGB("--color-text-60"), + 70: convertToRGB("--color-text-70"), + 80: convertToRGB("--color-text-80"), + 90: convertToRGB("--color-text-90"), + 100: convertToRGB("--color-text-100"), + 200: convertToRGB("--color-text-200"), + 300: convertToRGB("--color-text-300"), + 400: convertToRGB("--color-text-400"), + 500: convertToRGB("--color-text-500"), + 600: convertToRGB("--color-text-600"), + 700: convertToRGB("--color-text-700"), + 800: convertToRGB("--color-text-800"), + 900: convertToRGB("--color-text-900"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-text-100"), + }, + border: { + 0: "rgb(255, 255, 255)", + 100: convertToRGB("--color-border-100"), + 200: convertToRGB("--color-border-200"), + 300: convertToRGB("--color-border-300"), + 400: convertToRGB("--color-border-400"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-border-200"), + }, + sidebar: { + background: { + 0: "rgb(255, 255, 255)", + 10: convertToRGB("--color-sidebar-background-10"), + 20: convertToRGB("--color-sidebar-background-20"), + 30: convertToRGB("--color-sidebar-background-30"), + 40: convertToRGB("--color-sidebar-background-40"), + 50: convertToRGB("--color-sidebar-background-50"), + 60: convertToRGB("--color-sidebar-background-60"), + 70: convertToRGB("--color-sidebar-background-70"), + 80: convertToRGB("--color-sidebar-background-80"), + 90: convertToRGB("--color-sidebar-background-90"), + 100: convertToRGB("--color-sidebar-background-100"), + 200: convertToRGB("--color-sidebar-background-200"), + 300: convertToRGB("--color-sidebar-background-300"), + 400: convertToRGB("--color-sidebar-background-400"), + 500: convertToRGB("--color-sidebar-background-500"), + 600: convertToRGB("--color-sidebar-background-600"), + 700: convertToRGB("--color-sidebar-background-700"), + 800: convertToRGB("--color-sidebar-background-800"), + 900: convertToRGB("--color-sidebar-background-900"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-sidebar-background-100"), + }, + text: { + 0: "rgb(255, 255, 255)", + 10: convertToRGB("--color-sidebar-text-10"), + 20: convertToRGB("--color-sidebar-text-20"), + 30: convertToRGB("--color-sidebar-text-30"), + 40: convertToRGB("--color-sidebar-text-40"), + 50: convertToRGB("--color-sidebar-text-50"), + 60: convertToRGB("--color-sidebar-text-60"), + 70: convertToRGB("--color-sidebar-text-70"), + 80: convertToRGB("--color-sidebar-text-80"), + 90: convertToRGB("--color-sidebar-text-90"), + 100: convertToRGB("--color-sidebar-text-100"), + 200: convertToRGB("--color-sidebar-text-200"), + 300: convertToRGB("--color-sidebar-text-300"), + 400: convertToRGB("--color-sidebar-text-400"), + 500: convertToRGB("--color-sidebar-text-500"), + 600: convertToRGB("--color-sidebar-text-600"), + 700: convertToRGB("--color-sidebar-text-700"), + 800: convertToRGB("--color-sidebar-text-800"), + 900: convertToRGB("--color-sidebar-text-900"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-sidebar-text-100"), + }, + border: { + 0: "rgb(255, 255, 255)", + 100: convertToRGB("--color-sidebar-border-100"), + 200: convertToRGB("--color-sidebar-border-200"), + 300: convertToRGB("--color-sidebar-border-300"), + 400: convertToRGB("--color-sidebar-border-400"), + 1000: "rgb(0, 0, 0)", + DEFAULT: convertToRGB("--color-sidebar-border-200"), + }, + }, + backdrop: "#131313", + }, + }, + keyframes: { + leftToaster: { + "0%": { left: "-20rem" }, + "100%": { left: "0" }, + }, + rightToaster: { + "0%": { right: "-20rem" }, + "100%": { right: "0" }, + }, + }, + typography: ({ theme }) => ({ + brand: { + css: { + "--tw-prose-body": convertToRGB("--color-text-100"), + "--tw-prose-p": convertToRGB("--color-text-100"), + "--tw-prose-headings": convertToRGB("--color-text-100"), + "--tw-prose-lead": convertToRGB("--color-text-100"), + "--tw-prose-links": convertToRGB("--color-primary-100"), + "--tw-prose-bold": convertToRGB("--color-text-100"), + "--tw-prose-counters": convertToRGB("--color-text-100"), + "--tw-prose-bullets": convertToRGB("--color-text-100"), + "--tw-prose-hr": convertToRGB("--color-text-100"), + "--tw-prose-quotes": convertToRGB("--color-text-100"), + "--tw-prose-quote-borders": convertToRGB("--color-border"), + "--tw-prose-code": convertToRGB("--color-text-100"), + "--tw-prose-pre-code": convertToRGB("--color-text-100"), + "--tw-prose-pre-bg": convertToRGB("--color-background-100"), + "--tw-prose-th-borders": convertToRGB("--color-border"), + "--tw-prose-td-borders": convertToRGB("--color-border"), + }, + }, + }), + }, + fontFamily: { + custom: ["Inter", "sans-serif"], + }, + }, + plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")], +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 63e41b917..6a9132fca 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,6 +17,7 @@ "next": "12.3.2", "react": "^18.2.0", "tsconfig": "*", + "tailwind-config-custom": "*", "typescript": "4.7.4" } } diff --git a/packages/ui/postcss.config.js b/packages/ui/postcss.config.js new file mode 100644 index 000000000..129aa7f59 --- /dev/null +++ b/packages/ui/postcss.config.js @@ -0,0 +1 @@ +module.exports = require("tailwind-config-custom/postcss.config"); diff --git a/packages/ui/tailwind.config.js b/packages/ui/tailwind.config.js new file mode 100644 index 000000000..1e1e59826 --- /dev/null +++ b/packages/ui/tailwind.config.js @@ -0,0 +1 @@ +module.exports = require("tailwind-config-custom/tailwind.config"); diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 8c357fac6..cd6c94d6e 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,9 +1,5 @@ { - "extends": "../tsconfig/nextjs.json", + "extends": "tsconfig/react-library.json", "include": ["."], - "exclude": ["dist", "build", "node_modules"], - "compilerOptions": { - "jsx": "react-jsx", - "lib": ["DOM"] - } + "exclude": ["dist", "build", "node_modules"] } diff --git a/replace-env-vars.sh b/replace-env-vars.sh deleted file mode 100644 index 949ffd7d7..000000000 --- a/replace-env-vars.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -FROM=$1 -TO=$2 -DIRECTORY=$3 - -if [ "${FROM}" = "${TO}" ]; then - echo "Nothing to replace, the value is already set to ${TO}." - - exit 0 -fi - -# Only perform action if $FROM and $TO are different. -echo "Replacing all statically built instances of $FROM with this string $TO ." - -grep -R -la "${FROM}" $DIRECTORY/.next | xargs -I{} sed -i "s|$FROM|$TO|g" "{}" diff --git a/setup.sh b/setup.sh index 235e1a977..87c0f445b 100755 --- a/setup.sh +++ b/setup.sh @@ -5,15 +5,12 @@ cp ./.env.example ./.env export LC_ALL=C export LC_CTYPE=C - -# Generate the NEXT_PUBLIC_API_BASE_URL with given IP -echo -e "\nNEXT_PUBLIC_API_BASE_URL=$1" >> ./.env +cp ./web/.env.example ./web/.env +cp ./space/.env.example ./space/.env +cp ./apiserver/.env.example ./apiserver/.env # Generate the SECRET_KEY that will be used by django -echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./.env - -# WEB_URL for email redirection and image saving -echo -e "WEB_URL=$1" >> ./.env +echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env # Generate Prompt for taking tiptap auth key echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token \e[0m \n" @@ -21,9 +18,7 @@ echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token echo -e "\e[1;38m 1. Head over to TipTap cloud's Pro Extensions Page, https://collab.tiptap.dev/pro-extensions \e[0m" echo -e "\e[1;38m 2. Copy the token given to you under the first paragraph, after 'Here it is' \e[0m \n" -read -p $'\e[1;32m Please Enter Your TipTap Pro Extensions Authentication Token: \e[0m \e[1;36m' authToken - +read -p $'\e[1;32m Please Enter Your TipTap Pro Extensions Authentication Token: \e[0m \e[1;36m' authToken echo "@tiptap-pro:registry=https://registry.tiptap.dev/ -//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc - +//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc \ No newline at end of file diff --git a/space/.env.example b/space/.env.example index 238f70854..56e9f1e95 100644 --- a/space/.env.example +++ b/space/.env.example @@ -1,8 +1,4 @@ -# Base url for the API requests -NEXT_PUBLIC_API_BASE_URL="" -# Public boards deploy URL -NEXT_PUBLIC_DEPLOY_URL="" # Google Client ID for Google OAuth NEXT_PUBLIC_GOOGLE_CLIENTID="" # Flag to toggle OAuth -NEXT_PUBLIC_ENABLE_OAUTH=1 \ No newline at end of file +NEXT_PUBLIC_ENABLE_OAUTH=0 \ No newline at end of file diff --git a/space/.eslintrc.js b/space/.eslintrc.js index 38e6a5f4c..c8df60750 100644 --- a/space/.eslintrc.js +++ b/space/.eslintrc.js @@ -1,7 +1,4 @@ module.exports = { root: true, extends: ["custom"], - rules: { - "@next/next/no-img-element": "off", - }, }; diff --git a/space/Dockerfile.space b/space/Dockerfile.space index 963dad136..12c309134 100644 --- a/space/Dockerfile.space +++ b/space/Dockerfile.space @@ -1,7 +1,6 @@ FROM node:18-alpine AS builder RUN apk add --no-cache libc6-compat WORKDIR /app -ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER RUN yarn global add turbo COPY . . @@ -20,19 +19,16 @@ RUN yarn install --network-timeout 500000 COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json -COPY replace-env-vars.sh /usr/local/bin/ USER root -RUN chmod +x /usr/local/bin/replace-env-vars.sh -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 +ARG NEXT_PUBLIC_API_BASE_URL="" ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL +ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX RUN yarn turbo run build --filter=space -RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} space - FROM node:18-alpine AS runner WORKDIR /app @@ -48,14 +44,14 @@ COPY --from=installer --chown=captain:plane /app/space/.next/standalone ./ COPY --from=installer --chown=captain:plane /app/space/.next ./space/.next COPY --from=installer --chown=captain:plane /app/space/public ./space/public -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 +ARG NEXT_PUBLIC_API_BASE_URL="" ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1 -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX + +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL +ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX USER root -COPY replace-env-vars.sh /usr/local/bin/ COPY start.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/replace-env-vars.sh RUN chmod +x /usr/local/bin/start.sh USER captain diff --git a/space/components/accounts/email-password-form.tsx b/space/components/accounts/email-password-form.tsx index 23742eefe..b00740a15 100644 --- a/space/components/accounts/email-password-form.tsx +++ b/space/components/accounts/email-password-form.tsx @@ -1,9 +1,6 @@ import React, { useState } from "react"; - import { useRouter } from "next/router"; import Link from "next/link"; - -// react hook form import { useForm } from "react-hook-form"; // components import { EmailResetPasswordForm } from "./email-reset-password-form"; diff --git a/space/components/accounts/sign-in.tsx b/space/components/accounts/sign-in.tsx index ed55f7697..d3c29103d 100644 --- a/space/components/accounts/sign-in.tsx +++ b/space/components/accounts/sign-in.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import Image from "next/image"; import { useRouter } from "next/router"; @@ -13,7 +13,7 @@ import useToast from "hooks/use-toast"; // components import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts"; // images -const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces/" : ""; +const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : ""; export const SignInView = observer(() => { const { user: userStore } = useMobxStore(); diff --git a/space/helpers/common.helper.ts b/space/helpers/common.helper.ts new file mode 100644 index 000000000..758d7c370 --- /dev/null +++ b/space/helpers/common.helper.ts @@ -0,0 +1 @@ +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ? process.env.NEXT_PUBLIC_API_BASE_URL : ""; diff --git a/space/package.json b/space/package.json index f2bb39df6..2cf52bbf4 100644 --- a/space/package.json +++ b/space/package.json @@ -17,7 +17,6 @@ "@heroicons/react": "^2.0.12", "@mui/icons-material": "^5.14.1", "@mui/material": "^5.14.1", - "@tailwindcss/typography": "^0.5.9", "@tiptap-pro/extension-unique-id": "^2.1.0", "@tiptap/extension-code-block-lowlight": "^2.0.4", "@tiptap/extension-color": "^2.0.4", @@ -62,7 +61,6 @@ "uuid": "^9.0.0" }, "devDependencies": { - "@tailwindcss/typography": "^0.5.9", "@types/js-cookie": "^3.0.3", "@types/node": "18.14.1", "@types/nprogress": "^0.2.0", @@ -70,12 +68,10 @@ "@types/react-dom": "18.0.11", "@types/uuid": "^9.0.1", "@typescript-eslint/eslint-plugin": "^5.48.2", - "autoprefixer": "^10.4.13", "eslint": "8.34.0", "eslint-config-custom": "*", "eslint-config-next": "13.2.1", - "postcss": "^8.4.21", "tsconfig": "*", - "tailwindcss": "^3.2.7" + "tailwind-config-custom": "*" } } diff --git a/space/pages/onboarding/index.tsx b/space/pages/onboarding/index.tsx index 5cb168d38..12b09641b 100644 --- a/space/pages/onboarding/index.tsx +++ b/space/pages/onboarding/index.tsx @@ -2,22 +2,16 @@ import React, { useEffect } from "react"; // mobx import { observer } from "mobx-react-lite"; import { useMobxStore } from "lib/mobx/store-provider"; -// services -import authenticationService from "services/authentication.service"; -// hooks -import useToast from "hooks/use-toast"; // components import { OnBoardingForm } from "components/accounts/onboarding-form"; -const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces/" : ""; +const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : ""; const OnBoardingPage = () => { const { user: userStore } = useMobxStore(); const user = userStore?.currentUser; - const { setToastAlert } = useToast(); - useEffect(() => { const user = userStore?.currentUser; diff --git a/space/postcss.config.js b/space/postcss.config.js index cbfea5ea2..129aa7f59 100644 --- a/space/postcss.config.js +++ b/space/postcss.config.js @@ -1,7 +1 @@ -module.exports = { - plugins: { - "tailwindcss/nesting": {}, - tailwindcss: {}, - autoprefixer: {}, - }, -}; +module.exports = require("tailwind-config-custom/postcss.config"); diff --git a/space/services/authentication.service.ts b/space/services/authentication.service.ts index a6f1ec90f..4d861994f 100644 --- a/space/services/authentication.service.ts +++ b/space/services/authentication.service.ts @@ -1,9 +1,10 @@ // services import APIService from "services/api.service"; +import { API_BASE_URL } from "helpers/common.helper"; class AuthService extends APIService { constructor() { - super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + super(API_BASE_URL); } async emailLogin(data: any) { diff --git a/space/services/file.service.ts b/space/services/file.service.ts index 5ef34fc76..d9783d29c 100644 --- a/space/services/file.service.ts +++ b/space/services/file.service.ts @@ -1,7 +1,5 @@ -// services import APIService from "services/api.service"; - -const { NEXT_PUBLIC_API_BASE_URL } = process.env; +import { API_BASE_URL } from "helpers/common.helper"; interface UnSplashImage { id: string; @@ -29,7 +27,7 @@ interface UnSplashImageUrls { class FileServices extends APIService { constructor() { - super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + super(API_BASE_URL); } async uploadFile(workspaceSlug: string, file: FormData): Promise { diff --git a/space/services/issue.service.ts b/space/services/issue.service.ts index 835778fb2..5feb1b00b 100644 --- a/space/services/issue.service.ts +++ b/space/services/issue.service.ts @@ -1,9 +1,10 @@ // services import APIService from "services/api.service"; +import { API_BASE_URL } from "helpers/common.helper"; class IssueService extends APIService { constructor() { - super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + super(API_BASE_URL); } async getPublicIssues(workspace_slug: string, project_slug: string, params: any): Promise { diff --git a/space/services/project.service.ts b/space/services/project.service.ts index 291a5f323..0d6eca951 100644 --- a/space/services/project.service.ts +++ b/space/services/project.service.ts @@ -1,9 +1,10 @@ // services import APIService from "services/api.service"; +import { API_BASE_URL } from "helpers/common.helper"; class ProjectService extends APIService { constructor() { - super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + super(API_BASE_URL); } async getProjectSettings(workspace_slug: string, project_slug: string): Promise { diff --git a/space/services/user.service.ts b/space/services/user.service.ts index 9a324bb95..21e9f941e 100644 --- a/space/services/user.service.ts +++ b/space/services/user.service.ts @@ -1,9 +1,10 @@ // services import APIService from "services/api.service"; +import { API_BASE_URL } from "helpers/common.helper"; class UserService extends APIService { constructor() { - super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + super(API_BASE_URL); } async currentUser(): Promise { diff --git a/space/tailwind.config.js b/space/tailwind.config.js index 0347ad9f9..1e1e59826 100644 --- a/space/tailwind.config.js +++ b/space/tailwind.config.js @@ -1,203 +1 @@ -/** @type {import('tailwindcss').Config} */ - -const convertToRGB = (variableName) => `rgba(var(${variableName}))`; - -module.exports = { - content: [ - "./app/**/*.{js,ts,jsx,tsx}", - "./pages/**/*.{js,ts,jsx,tsx}", - "./layouts/**/*.tsx", - "./components/**/*.{js,ts,jsx,tsx}", - "./constants/**/*.{js,ts,jsx,tsx}", - ], - 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: { - 0: "rgb(255, 255, 255)", - 10: convertToRGB("--color-primary-10"), - 20: convertToRGB("--color-primary-20"), - 30: convertToRGB("--color-primary-30"), - 40: convertToRGB("--color-primary-40"), - 50: convertToRGB("--color-primary-50"), - 60: convertToRGB("--color-primary-60"), - 70: convertToRGB("--color-primary-70"), - 80: convertToRGB("--color-primary-80"), - 90: convertToRGB("--color-primary-90"), - 100: convertToRGB("--color-primary-100"), - 200: convertToRGB("--color-primary-200"), - 300: convertToRGB("--color-primary-300"), - 400: convertToRGB("--color-primary-400"), - 500: convertToRGB("--color-primary-500"), - 600: convertToRGB("--color-primary-600"), - 700: convertToRGB("--color-primary-700"), - 800: convertToRGB("--color-primary-800"), - 900: convertToRGB("--color-primary-900"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-primary-100"), - }, - background: { - 0: "rgb(255, 255, 255)", - 10: convertToRGB("--color-background-10"), - 20: convertToRGB("--color-background-20"), - 30: convertToRGB("--color-background-30"), - 40: convertToRGB("--color-background-40"), - 50: convertToRGB("--color-background-50"), - 60: convertToRGB("--color-background-60"), - 70: convertToRGB("--color-background-70"), - 80: convertToRGB("--color-background-80"), - 90: convertToRGB("--color-background-90"), - 100: convertToRGB("--color-background-100"), - 200: convertToRGB("--color-background-200"), - 300: convertToRGB("--color-background-300"), - 400: convertToRGB("--color-background-400"), - 500: convertToRGB("--color-background-500"), - 600: convertToRGB("--color-background-600"), - 700: convertToRGB("--color-background-700"), - 800: convertToRGB("--color-background-800"), - 900: convertToRGB("--color-background-900"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-background-100"), - }, - text: { - 0: "rgb(255, 255, 255)", - 10: convertToRGB("--color-text-10"), - 20: convertToRGB("--color-text-20"), - 30: convertToRGB("--color-text-30"), - 40: convertToRGB("--color-text-40"), - 50: convertToRGB("--color-text-50"), - 60: convertToRGB("--color-text-60"), - 70: convertToRGB("--color-text-70"), - 80: convertToRGB("--color-text-80"), - 90: convertToRGB("--color-text-90"), - 100: convertToRGB("--color-text-100"), - 200: convertToRGB("--color-text-200"), - 300: convertToRGB("--color-text-300"), - 400: convertToRGB("--color-text-400"), - 500: convertToRGB("--color-text-500"), - 600: convertToRGB("--color-text-600"), - 700: convertToRGB("--color-text-700"), - 800: convertToRGB("--color-text-800"), - 900: convertToRGB("--color-text-900"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-text-100"), - }, - border: { - 0: "rgb(255, 255, 255)", - 100: convertToRGB("--color-border-100"), - 200: convertToRGB("--color-border-200"), - 300: convertToRGB("--color-border-300"), - 400: convertToRGB("--color-border-400"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-border-200"), - }, - sidebar: { - background: { - 0: "rgb(255, 255, 255)", - 10: convertToRGB("--color-sidebar-background-10"), - 20: convertToRGB("--color-sidebar-background-20"), - 30: convertToRGB("--color-sidebar-background-30"), - 40: convertToRGB("--color-sidebar-background-40"), - 50: convertToRGB("--color-sidebar-background-50"), - 60: convertToRGB("--color-sidebar-background-60"), - 70: convertToRGB("--color-sidebar-background-70"), - 80: convertToRGB("--color-sidebar-background-80"), - 90: convertToRGB("--color-sidebar-background-90"), - 100: convertToRGB("--color-sidebar-background-100"), - 200: convertToRGB("--color-sidebar-background-200"), - 300: convertToRGB("--color-sidebar-background-300"), - 400: convertToRGB("--color-sidebar-background-400"), - 500: convertToRGB("--color-sidebar-background-500"), - 600: convertToRGB("--color-sidebar-background-600"), - 700: convertToRGB("--color-sidebar-background-700"), - 800: convertToRGB("--color-sidebar-background-800"), - 900: convertToRGB("--color-sidebar-background-900"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-sidebar-background-100"), - }, - text: { - 0: "rgb(255, 255, 255)", - 10: convertToRGB("--color-sidebar-text-10"), - 20: convertToRGB("--color-sidebar-text-20"), - 30: convertToRGB("--color-sidebar-text-30"), - 40: convertToRGB("--color-sidebar-text-40"), - 50: convertToRGB("--color-sidebar-text-50"), - 60: convertToRGB("--color-sidebar-text-60"), - 70: convertToRGB("--color-sidebar-text-70"), - 80: convertToRGB("--color-sidebar-text-80"), - 90: convertToRGB("--color-sidebar-text-90"), - 100: convertToRGB("--color-sidebar-text-100"), - 200: convertToRGB("--color-sidebar-text-200"), - 300: convertToRGB("--color-sidebar-text-300"), - 400: convertToRGB("--color-sidebar-text-400"), - 500: convertToRGB("--color-sidebar-text-500"), - 600: convertToRGB("--color-sidebar-text-600"), - 700: convertToRGB("--color-sidebar-text-700"), - 800: convertToRGB("--color-sidebar-text-800"), - 900: convertToRGB("--color-sidebar-text-900"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-sidebar-text-100"), - }, - border: { - 0: "rgb(255, 255, 255)", - 100: convertToRGB("--color-sidebar-border-100"), - 200: convertToRGB("--color-sidebar-border-200"), - 300: convertToRGB("--color-sidebar-border-300"), - 400: convertToRGB("--color-sidebar-border-400"), - 1000: "rgb(0, 0, 0)", - DEFAULT: convertToRGB("--color-sidebar-border-200"), - }, - }, - backdrop: "#131313", - }, - }, - typography: ({ theme }) => ({ - brand: { - css: { - "--tw-prose-body": convertToRGB("--color-text-100"), - "--tw-prose-p": convertToRGB("--color-text-100"), - "--tw-prose-headings": convertToRGB("--color-text-100"), - "--tw-prose-lead": convertToRGB("--color-text-100"), - "--tw-prose-links": convertToRGB("--color-primary-100"), - "--tw-prose-bold": convertToRGB("--color-text-100"), - "--tw-prose-counters": convertToRGB("--color-text-100"), - "--tw-prose-bullets": convertToRGB("--color-text-100"), - "--tw-prose-hr": convertToRGB("--color-text-100"), - "--tw-prose-quotes": convertToRGB("--color-text-100"), - "--tw-prose-quote-borders": convertToRGB("--color-border"), - "--tw-prose-code": convertToRGB("--color-text-100"), - "--tw-prose-pre-code": convertToRGB("--color-text-100"), - "--tw-prose-pre-bg": convertToRGB("--color-background-100"), - "--tw-prose-th-borders": convertToRGB("--color-border"), - "--tw-prose-td-borders": convertToRGB("--color-border"), - }, - }, - }), - }, - fontFamily: { - custom: ["Inter", "sans-serif"], - }, - }, - plugins: [require("@tailwindcss/typography")], -}; +module.exports = require("tailwind-config-custom/tailwind.config"); diff --git a/start.sh b/start.sh index dcb97db6d..2685c3826 100644 --- a/start.sh +++ b/start.sh @@ -1,9 +1,5 @@ #!/bin/sh set -x -# Replace the statically built BUILT_NEXT_PUBLIC_API_BASE_URL with run-time NEXT_PUBLIC_API_BASE_URL -# NOTE: if these values are the same, this will be skipped. -/usr/local/bin/replace-env-vars.sh "$BUILT_NEXT_PUBLIC_API_BASE_URL" "$NEXT_PUBLIC_API_BASE_URL" $2 - echo "Starting Plane Frontend.." node $1 diff --git a/web/.env.example b/web/.env.example index 50a6209b2..88a2064c5 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,5 +1,3 @@ -# Base url for the API requests -NEXT_PUBLIC_API_BASE_URL="" # Extra image domains that need to be added for Next Image NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS= # Google Client ID for Google OAuth @@ -23,4 +21,4 @@ NEXT_PUBLIC_SLACK_CLIENT_ID="" # For Telemetry, set it to "app.plane.so" NEXT_PUBLIC_PLAUSIBLE_DOMAIN="" # Public boards deploy URL -NEXT_PUBLIC_DEPLOY_URL="" \ No newline at end of file +NEXT_PUBLIC_DEPLOY_URL="http://localhost:3000/spaces" \ No newline at end of file diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 38e6a5f4c..c8df60750 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -1,7 +1,4 @@ module.exports = { root: true, extends: ["custom"], - rules: { - "@next/next/no-img-element": "off", - }, }; diff --git a/web/Dockerfile.web b/web/Dockerfile.web index 40946fa2d..d9260e61d 100644 --- a/web/Dockerfile.web +++ b/web/Dockerfile.web @@ -2,7 +2,6 @@ FROM node:18-alpine AS builder RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app -ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER RUN yarn global add turbo COPY . . @@ -14,8 +13,8 @@ FROM node:18-alpine AS installer RUN apk add --no-cache libc6-compat WORKDIR /app -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 -ARG NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces +ARG NEXT_PUBLIC_API_BASE_URL="" +ARG NEXT_PUBLIC_DEPLOY_URL="" # First install the dependencies (as they change less often) COPY .gitignore .gitignore @@ -26,18 +25,12 @@ RUN yarn install --network-timeout 500000 # Build the project COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json -COPY replace-env-vars.sh /usr/local/bin/ USER root -RUN chmod +x /usr/local/bin/replace-env-vars.sh - -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ - BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ - NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL +ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL RUN yarn turbo run build --filter=web -RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} web - FROM node:18-alpine AS runner WORKDIR /app @@ -52,20 +45,15 @@ COPY --from=installer /app/web/package.json . # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=installer --chown=captain:plane /app/web/.next/standalone ./ - COPY --from=installer --chown=captain:plane /app/web/.next ./web/.next -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 -ARG NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces - -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ - BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ - NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL +ARG NEXT_PUBLIC_API_BASE_URL="" +ARG NEXT_PUBLIC_DEPLOY_URL="" +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL +ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL USER root -COPY replace-env-vars.sh /usr/local/bin/ COPY start.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/replace-env-vars.sh RUN chmod +x /usr/local/bin/start.sh USER captain diff --git a/web/components/automation/auto-archive-automation.tsx b/web/components/automation/auto-archive-automation.tsx index 07ac86460..bb4e72e0c 100644 --- a/web/components/automation/auto-archive-automation.tsx +++ b/web/components/automation/auto-archive-automation.tsx @@ -3,8 +3,8 @@ import React, { useState } from "react"; // component import { CustomSelect, ToggleSwitch } from "components/ui"; import { SelectMonthModal } from "components/automation"; -// icons -import { ChevronDownIcon } from "@heroicons/react/24/outline"; +// icon +import { ArchiveRestore } from "lucide-react"; // constants import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; // types @@ -28,14 +28,18 @@ export const AutoArchiveAutomation: React.FC = ({ projectDetails, handleC handleClose={() => setmonthModal(false)} handleChange={handleChange} /> -
-
-
-

Auto-archive closed issues

-

- Plane will automatically archive issues that have been completed or cancelled for the - configured time period. -

+
+
+
+
+ +
+
+

Auto-archive closed issues

+

+ Plane will auto archive issues that have been completed or canceled. +

+
= ({ projectDetails, handleC size="sm" />
- {projectDetails?.archive_in !== 0 && ( -
-
- Auto-archive issues that are closed for -
-
- { - handleChange({ archive_in: val }); - }} - input - verticalPosition="top" - width="w-full" - > - <> - {PROJECT_AUTOMATION_MONTHS.map((month) => ( - - {month.label} - - ))} - - - + {projectDetails?.archive_in !== 0 && ( +
+
+
+ Auto-archive issues that are closed for +
+
+ { + handleChange({ archive_in: val }); + }} + input + verticalPosition="bottom" + width="w-full" + > + <> + {PROJECT_AUTOMATION_MONTHS.map((month) => ( + + {month.label} + + ))} + + + + +
)} diff --git a/web/components/automation/auto-close-automation.tsx b/web/components/automation/auto-close-automation.tsx index ad65714aa..8235c8063 100644 --- a/web/components/automation/auto-close-automation.tsx +++ b/web/components/automation/auto-close-automation.tsx @@ -5,11 +5,12 @@ import useSWR from "swr"; import { useRouter } from "next/router"; // component -import { CustomSearchSelect, CustomSelect, ToggleSwitch } from "components/ui"; +import { CustomSearchSelect, CustomSelect, Icon, ToggleSwitch } from "components/ui"; import { SelectMonthModal } from "components/automation"; // icons -import { ChevronDownIcon, Squares2X2Icon } from "@heroicons/react/24/outline"; +import { Squares2X2Icon } from "@heroicons/react/24/outline"; import { StateGroupIcon } from "components/icons"; +import { ArchiveX } from "lucide-react"; // services import stateService from "services/state.service"; // constants @@ -76,14 +77,18 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha handleChange={handleChange} /> -
-
-
-

Auto-close inactive issues

-

- Plane will automatically close the issues that have not been updated for the - configured time period. -

+
+
+
+
+ +
+
+

Auto-close issues

+

+ Plane will automatically close issue that haven’t been completed or canceled. +

+
= ({ projectDetails, handleCha size="sm" />
+ {projectDetails?.close_in !== 0 && ( -
-
-
- Auto-close issues that are inactive for +
+
+
+
+ Auto-close issues that are inactive for +
+
+ { + handleChange({ close_in: val }); + }} + input + width="w-full" + > + <> + {PROJECT_AUTOMATION_MONTHS.map((month) => ( + + {month.label} + + ))} + + + +
-
- { - handleChange({ close_in: val }); - }} - input - width="w-full" - > - <> - {PROJECT_AUTOMATION_MONTHS.map((month) => ( - - {month.label} - - ))} - - - -
-
-
-
Auto-close Status
-
- - {selectedOption ? ( - - ) : currentDefaultState ? ( - - ) : ( - - )} - {selectedOption?.name - ? selectedOption.name - : currentDefaultState?.name ?? ( - State - )} -
- } - onChange={(val: string) => { - handleChange({ default_state: val }); - }} - options={options} - disabled={!multipleOptions} - width="w-full" - input - /> + +
+
Auto-close Status
+
+ + {selectedOption ? ( + + ) : currentDefaultState ? ( + + ) : ( + + )} + {selectedOption?.name + ? selectedOption.name + : currentDefaultState?.name ?? ( + State + )} +
+ } + onChange={(val: string) => { + handleChange({ default_state: val }); + }} + options={options} + disabled={!multipleOptions} + width="w-full" + input + /> +
diff --git a/web/components/automation/select-month-modal.tsx b/web/components/automation/select-month-modal.tsx index b91c03391..18239d62b 100644 --- a/web/components/automation/select-month-modal.tsx +++ b/web/components/automation/select-month-modal.tsx @@ -104,7 +104,7 @@ export const SelectMonthModal: React.FC = ({ as="h3" className="text-lg font-medium leading-6 text-custom-text-100" > - Customize Time Range + Customise Time Range
diff --git a/web/components/core/image-picker-popover.tsx b/web/components/core/image-picker-popover.tsx index 5f13d960e..957f1131c 100644 --- a/web/components/core/image-picker-popover.tsx +++ b/web/components/core/image-picker-popover.tsx @@ -20,6 +20,7 @@ import fileService from "services/file.service"; import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui"; // hooks import useWorkspaceDetails from "hooks/use-workspace-details"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; const unsplashEnabled = process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" || @@ -67,6 +68,8 @@ export const ImagePickerPopover: React.FC = ({ fileService.getUnsplashImages(1, searchParams) ); + const imagePickerRef = useRef(null); + const { workspaceDetails } = useWorkspaceDetails(); const onDrop = useCallback((acceptedFiles: File[]) => { @@ -116,12 +119,14 @@ export const ImagePickerPopover: React.FC = ({ onChange(images[0].urls.regular); }, [value, onChange, images]); + useOutsideClickDetector(imagePickerRef, () => setIsOpen(false)); + if (!unsplashEnabled) return null; return ( setIsOpen((prev) => !prev)} disabled={disabled} > @@ -137,7 +142,10 @@ export const ImagePickerPopover: React.FC = ({ leaveTo="transform opacity-0 scale-95" > -
+
diff --git a/web/components/core/views/all-views.tsx b/web/components/core/views/all-views.tsx index 3b95ed863..750c1a552 100644 --- a/web/components/core/views/all-views.tsx +++ b/web/components/core/views/all-views.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useState } from "react"; import { useRouter } from "next/router"; @@ -77,6 +77,8 @@ export const AllViews: React.FC = ({ const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const [myIssueProjectId, setMyIssueProjectId] = useState(null); + const { user } = useUser(); const { memberRole } = useProjectMyMembership(); @@ -90,6 +92,10 @@ export const AllViews: React.FC = ({ ); const states = getStatesList(stateGroups); + const handleMyIssueOpen = (issue: IIssue) => { + setMyIssueProjectId(issue.project); + }; + const handleTrashBox = useCallback( (isDragging: boolean) => { if (isDragging && !trashBox) setTrashBox(true); @@ -128,6 +134,8 @@ export const AllViews: React.FC = ({ handleIssueAction={handleIssueAction} openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null} removeIssue={removeIssue} + myIssueProjectId={myIssueProjectId} + handleMyIssueOpen={handleMyIssueOpen} disableUserActions={disableUserActions} disableAddIssueOption={disableAddIssueOption} user={user} @@ -143,6 +151,8 @@ export const AllViews: React.FC = ({ handleIssueAction={handleIssueAction} handleTrashBox={handleTrashBox} openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null} + myIssueProjectId={myIssueProjectId} + handleMyIssueOpen={handleMyIssueOpen} removeIssue={removeIssue} states={states} user={user} @@ -166,7 +176,9 @@ export const AllViews: React.FC = ({ userAuth={memberRole} /> ) : ( - displayFilters?.layout === "gantt_chart" && + displayFilters?.layout === "gantt_chart" && ( + + ) )} ) : router.pathname.includes("archived-issues") ? ( diff --git a/web/components/core/views/board-view/all-boards.tsx b/web/components/core/views/board-view/all-boards.tsx index a51ac78ce..48fa0d6a6 100644 --- a/web/components/core/views/board-view/all-boards.tsx +++ b/web/components/core/views/board-view/all-boards.tsx @@ -1,5 +1,12 @@ +import { useRouter } from "next/router"; + +//hook +import useMyIssues from "hooks/my-issues/use-my-issues"; +import useIssuesView from "hooks/use-issues-view"; +import useProfileIssues from "hooks/use-profile-issues"; // components import { SingleBoard } from "components/core/views/board-view/single-board"; +import { IssuePeekOverview } from "components/issues"; // icons import { StateGroupIcon } from "components/icons"; // helpers @@ -16,6 +23,8 @@ type Props = { handleTrashBox: (isDragging: boolean) => void; openIssuesListModal?: (() => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null; + myIssueProjectId?: string | null; + handleMyIssueOpen?: (issue: IIssue) => void; states: IState[] | undefined; user: ICurrentUserResponse | undefined; userAuth: UserAuth; @@ -30,18 +39,42 @@ export const AllBoards: React.FC = ({ handleIssueAction, handleTrashBox, openIssuesListModal, + myIssueProjectId, + handleMyIssueOpen, removeIssue, states, user, userAuth, viewProps, }) => { + const router = useRouter(); + const { workspaceSlug, projectId, userId } = router.query; + + const isProfileIssue = + router.pathname.includes("assigned") || + router.pathname.includes("created") || + router.pathname.includes("subscribed"); + + const isMyIssue = router.pathname.includes("my-issues"); + + const { mutateIssues } = useIssuesView(); + const { mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); + const { mutateProfileIssues } = useProfileIssues(workspaceSlug?.toString(), userId?.toString()); + const { displayFilters, groupedIssues } = viewProps; console.log("viewProps", viewProps); return ( <> + + isMyIssue ? mutateMyIssues() : isProfileIssue ? mutateProfileIssues() : mutateIssues() + } + projectId={myIssueProjectId ? myIssueProjectId : projectId?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} + /> {groupedIssues ? (
{Object.keys(groupedIssues).map((singleGroup, index) => { @@ -65,6 +98,7 @@ export const AllBoards: React.FC = ({ handleIssueAction={handleIssueAction} handleTrashBox={handleTrashBox} openIssuesListModal={openIssuesListModal ?? null} + handleMyIssueOpen={handleMyIssueOpen} removeIssue={removeIssue} user={user} userAuth={userAuth} diff --git a/web/components/core/views/board-view/single-board.tsx b/web/components/core/views/board-view/single-board.tsx index fcc3a56bf..5b87f8aba 100644 --- a/web/components/core/views/board-view/single-board.tsx +++ b/web/components/core/views/board-view/single-board.tsx @@ -26,6 +26,7 @@ type Props = { handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleTrashBox: (isDragging: boolean) => void; openIssuesListModal?: (() => void) | null; + handleMyIssueOpen?: (issue: IIssue) => void; removeIssue: ((bridgeId: string, issueId: string) => void) | null; user: ICurrentUserResponse | undefined; userAuth: UserAuth; @@ -42,6 +43,7 @@ export const SingleBoard: React.FC = ({ handleIssueAction, handleTrashBox, openIssuesListModal, + handleMyIssueOpen, removeIssue, user, userAuth, @@ -50,7 +52,7 @@ export const SingleBoard: React.FC = ({ // collapse/expand const [isCollapsed, setIsCollapsed] = useState(true); - const { displayFilters, groupedIssues, properties } = viewProps; + const { displayFilters, groupedIssues } = viewProps; const router = useRouter(); const { cycleId, moduleId } = router.query; @@ -135,6 +137,7 @@ export const SingleBoard: React.FC = ({ makeIssueCopy={() => handleIssueAction(issue, "copy")} handleDeleteIssue={() => handleIssueAction(issue, "delete")} handleTrashBox={handleTrashBox} + handleMyIssueOpen={handleMyIssueOpen} removeIssue={() => { if (removeIssue && issue.bridge_id) removeIssue(issue.bridge_id, issue.id); diff --git a/web/components/core/views/board-view/single-issue.tsx b/web/components/core/views/board-view/single-issue.tsx index dc750babe..ffd4747d9 100644 --- a/web/components/core/views/board-view/single-issue.tsx +++ b/web/components/core/views/board-view/single-issue.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import Link from "next/link"; import { useRouter } from "next/router"; import { mutate } from "swr"; @@ -58,6 +57,7 @@ type Props = { index: number; editIssue: () => void; makeIssueCopy: () => void; + handleMyIssueOpen?: (issue: IIssue) => void; removeIssue?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; handleTrashBox: (isDragging: boolean) => void; @@ -75,6 +75,7 @@ export const SingleBoardIssue: React.FC = ({ index, editIssue, makeIssueCopy, + handleMyIssueOpen, removeIssue, groupTitle, handleDeleteIssue, @@ -187,6 +188,17 @@ export const SingleBoardIssue: React.FC = ({ useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); + const openPeekOverview = () => { + const { query } = router; + + if (handleMyIssueOpen) handleMyIssueOpen(issue); + + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: issue.id }, + }); + }; + const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; return ( @@ -286,16 +298,22 @@ export const SingleBoardIssue: React.FC = ({ )}
)} - - - {properties.key && ( -
- {issue.project_detail.identifier}-{issue.sequence_id} -
- )} -
{issue.name}
-
- + +
+ {properties.key && ( +
+ {issue.project_detail.identifier}-{issue.sequence_id} +
+ )} + +
+
= ({ const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; - const { calendarIssues, params, setDisplayFilters } = useCalendarIssuesView(); + const { calendarIssues, mutateIssues, params, displayFilters, setDisplayFilters } = + useCalendarIssuesView(); const totalDate = eachDayOfInterval({ start: calendarDates.startDate, @@ -160,84 +162,95 @@ export const CalendarView: React.FC = ({ }; useEffect(() => { - setDisplayFilters({ - calendar_date_range: `${renderDateFormat(startOfWeek(currentDate))};after,${renderDateFormat( - lastDayOfWeek(currentDate) - )};before`, - }); - }, [currentDate, setDisplayFilters]); + if (!displayFilters || displayFilters.calendar_date_range === "") + setDisplayFilters({ + calendar_date_range: `${renderDateFormat( + startOfWeek(currentDate) + )};after,${renderDateFormat(lastDayOfWeek(currentDate))};before`, + }); + }, [currentDate, displayFilters, setDisplayFilters]); const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; - return calendarIssues ? ( -
- -
- + return ( + <> + mutateIssues()} + projectId={projectId?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} + /> + {calendarIssues ? ( +
+ +
+ -
- {weeks.map((date, index) => (
- - {isMonthlyView - ? formatDate(date, "eee").substring(0, 3) - : formatDate(date, "eee")} - - {!isMonthlyView && {formatDate(date, "d")}} + {weeks.map((date, index) => ( +
+ + {isMonthlyView + ? formatDate(date, "eee").substring(0, 3) + : formatDate(date, "eee")} + + {!isMonthlyView && {formatDate(date, "d")}} +
+ ))}
- ))} -
-
- {currentViewDaysData.map((date, index) => ( - - ))} -
+
+ {currentViewDaysData.map((date, index) => ( + + ))} +
+
+
- -
- ) : ( -
- -
+ ) : ( +
+ +
+ )} + ); }; diff --git a/web/components/core/views/calendar-view/single-issue.tsx b/web/components/core/views/calendar-view/single-issue.tsx index f6c1cc2f7..3db571c99 100644 --- a/web/components/core/views/calendar-view/single-issue.tsx +++ b/web/components/core/views/calendar-view/single-issue.tsx @@ -1,6 +1,5 @@ import React, { useCallback } from "react"; -import Link from "next/link"; import { useRouter } from "next/router"; import { mutate } from "swr"; @@ -158,6 +157,15 @@ export const SingleCalendarIssue: React.FC = ({ ? Object.values(properties).some((value) => value === true) : false; + const openPeekOverview = () => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: issue.id }, + }); + }; + return (
= ({
)} - - - {properties.key && ( - - - {issue.project_detail?.identifier}-{issue.sequence_id} - - - )} - - {truncateText(issue.name, 25)} + + + {displayProperties && (
{properties.priority && ( diff --git a/web/components/core/views/gantt-chart-view/index.tsx b/web/components/core/views/gantt-chart-view/index.tsx index a881cb7aa..2cd10f95f 100644 --- a/web/components/core/views/gantt-chart-view/index.tsx +++ b/web/components/core/views/gantt-chart-view/index.tsx @@ -6,20 +6,24 @@ import { IssueGanttChartView } from "components/issues"; import { ModuleIssuesGanttChartView } from "components/modules"; import { ViewIssuesGanttChartView } from "components/views"; -export const GanttChartView = () => { +type Props = { + disableUserActions: boolean; +}; + +export const GanttChartView: React.FC = ({ disableUserActions }) => { const router = useRouter(); const { cycleId, moduleId, viewId } = router.query; return ( <> {cycleId ? ( - + ) : moduleId ? ( - + ) : viewId ? ( - + ) : ( - + )} ); diff --git a/web/components/core/views/issues-view.tsx b/web/components/core/views/issues-view.tsx index b4dd665dd..e0e7e8c94 100644 --- a/web/components/core/views/issues-view.tsx +++ b/web/components/core/views/issues-view.tsx @@ -19,7 +19,7 @@ import useIssuesProperties from "hooks/use-issue-properties"; import useProjectMembers from "hooks/use-project-members"; // components import { FiltersList, AllViews } from "components/core"; -import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +import { CreateUpdateIssueModal, DeleteIssueModal, IssuePeekOverview } from "components/issues"; import { CreateUpdateViewModal } from "components/views"; // ui import { PrimaryButton, SecondaryButton } from "components/ui"; @@ -462,6 +462,7 @@ export const IssuesView: React.FC = ({ data={issueToDelete} user={user} /> + {areFiltersApplied && ( <>
diff --git a/web/components/core/views/list-view/all-lists.tsx b/web/components/core/views/list-view/all-lists.tsx index 282e27755..bb0a7c0fb 100644 --- a/web/components/core/views/list-view/all-lists.tsx +++ b/web/components/core/views/list-view/all-lists.tsx @@ -1,5 +1,12 @@ +import { useRouter } from "next/router"; + +// hooks +import useMyIssues from "hooks/my-issues/use-my-issues"; +import useIssuesView from "hooks/use-issues-view"; +import useProfileIssues from "hooks/use-profile-issues"; // components import { SingleList } from "components/core/views/list-view/single-list"; +import { IssuePeekOverview } from "components/issues"; // types import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types"; @@ -9,6 +16,8 @@ type Props = { addIssueToGroup: (groupTitle: string) => void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; openIssuesListModal?: (() => void) | null; + myIssueProjectId?: string | null; + handleMyIssueOpen?: (issue: IIssue) => void; removeIssue: ((bridgeId: string, issueId: string) => void) | null; disableUserActions: boolean; disableAddIssueOption?: boolean; @@ -23,16 +32,39 @@ export const AllLists: React.FC = ({ disableUserActions, disableAddIssueOption = false, openIssuesListModal, + handleMyIssueOpen, + myIssueProjectId, removeIssue, states, user, userAuth, viewProps, }) => { + const router = useRouter(); + const { workspaceSlug, projectId, userId } = router.query; + + const isProfileIssue = + router.pathname.includes("assigned") || + router.pathname.includes("created") || + router.pathname.includes("subscribed"); + + const isMyIssue = router.pathname.includes("my-issues"); + const { mutateIssues } = useIssuesView(); + const { mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); + const { mutateProfileIssues } = useProfileIssues(workspaceSlug?.toString(), userId?.toString()); + const { displayFilters, groupedIssues } = viewProps; return ( <> + + isMyIssue ? mutateMyIssues() : isProfileIssue ? mutateProfileIssues() : mutateIssues() + } + projectId={myIssueProjectId ? myIssueProjectId : projectId?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} + /> {groupedIssues && (
{Object.keys(groupedIssues).map((singleGroup) => { @@ -51,6 +83,7 @@ export const AllLists: React.FC = ({ currentState={currentState} addIssueToGroup={() => addIssueToGroup(singleGroup)} handleIssueAction={handleIssueAction} + handleMyIssueOpen={handleMyIssueOpen} openIssuesListModal={openIssuesListModal} removeIssue={removeIssue} disableUserActions={disableUserActions} diff --git a/web/components/core/views/list-view/single-issue.tsx b/web/components/core/views/list-view/single-issue.tsx index 1e5d551c3..ab5c080ca 100644 --- a/web/components/core/views/list-view/single-issue.tsx +++ b/web/components/core/views/list-view/single-issue.tsx @@ -61,6 +61,7 @@ type Props = { makeIssueCopy: () => void; removeIssue?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; + handleMyIssueOpen?: (issue: IIssue) => void; disableUserActions: boolean; user: ICurrentUserResponse | undefined; userAuth: UserAuth; @@ -76,6 +77,7 @@ export const SingleListIssue: React.FC = ({ removeIssue, groupTitle, handleDeleteIssue, + handleMyIssueOpen, disableUserActions, user, userAuth, @@ -178,6 +180,16 @@ export const SingleListIssue: React.FC = ({ ? `/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}` : `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`; + const openPeekOverview = (issue: IIssue) => { + const { query } = router; + + if (handleMyIssueOpen) handleMyIssueOpen(issue); + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: issue.id }, + }); + }; + const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions || isArchivedIssues; @@ -220,23 +232,27 @@ export const SingleListIssue: React.FC = ({ }} >
void; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; openIssuesListModal?: (() => void) | null; + handleMyIssueOpen?: (issue: IIssue) => void; removeIssue: ((bridgeId: string, issueId: string) => void) | null; disableUserActions: boolean; disableAddIssueOption?: boolean; @@ -55,6 +56,7 @@ export const SingleList: React.FC = ({ addIssueToGroup, handleIssueAction, openIssuesListModal, + handleMyIssueOpen, removeIssue, disableUserActions, disableAddIssueOption = false, @@ -251,6 +253,7 @@ export const SingleList: React.FC = ({ editIssue={() => handleIssueAction(issue, "edit")} makeIssueCopy={() => handleIssueAction(issue, "copy")} handleDeleteIssue={() => handleIssueAction(issue, "delete")} + handleMyIssueOpen={handleMyIssueOpen} removeIssue={() => { if (removeIssue !== null && issue.bridge_id) removeIssue(issue.bridge_id, issue.id); diff --git a/web/components/cycles/gantt-chart/cycle-issues-layout.tsx b/web/components/cycles/gantt-chart/cycle-issues-layout.tsx index 1c78da096..b70b16f03 100644 --- a/web/components/cycles/gantt-chart/cycle-issues-layout.tsx +++ b/web/components/cycles/gantt-chart/cycle-issues-layout.tsx @@ -8,11 +8,15 @@ import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; import useProjectDetails from "hooks/use-project-details"; // components import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart"; -import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues"; +import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues"; // types import { IIssue } from "types"; -export const CycleIssuesGanttChartView = () => { +type Props = { + disableUserActions: boolean; +}; + +export const CycleIssuesGanttChartView: React.FC = ({ disableUserActions }) => { const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query; @@ -30,23 +34,31 @@ export const CycleIssuesGanttChartView = () => { const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; return ( -
- - updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) - } - SidebarBlockRender={IssueGanttSidebarBlock} - BlockRender={IssueGanttBlock} - enableBlockLeftResize={isAllowed} - enableBlockRightResize={isAllowed} - enableBlockMove={isAllowed} - enableReorder={displayFilters.order_by === "sort_order" && isAllowed} - bottomSpacing + <> + mutateGanttIssues()} + projectId={projectId?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} /> -
+
+ + updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) + } + SidebarBlockRender={IssueGanttSidebarBlock} + BlockRender={IssueGanttBlock} + enableBlockLeftResize={isAllowed} + enableBlockRightResize={isAllowed} + enableBlockMove={isAllowed} + enableReorder={displayFilters.order_by === "sort_order" && isAllowed} + bottomSpacing + /> +
+ ); }; diff --git a/web/components/emoji-icon-picker/index.tsx b/web/components/emoji-icon-picker/index.tsx index 7af3bb74f..ab4eb022e 100644 --- a/web/components/emoji-icon-picker/index.tsx +++ b/web/components/emoji-icon-picker/index.tsx @@ -1,8 +1,10 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; // headless ui import { Tab, Transition, Popover } from "@headlessui/react"; // react colors import { TwitterPicker } from "react-color"; +// hooks +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // types import { Props } from "./types"; // emojis @@ -36,6 +38,8 @@ const EmojiIconPicker: React.FC = ({ const [recentEmojis, setRecentEmojis] = useState([]); + const emojiPickerRef = useRef(null); + useEffect(() => { setRecentEmojis(getRecentEmojis()); }, []); @@ -44,6 +48,8 @@ const EmojiIconPicker: React.FC = ({ if (!value || value?.length === 0) onChange(getRandomEmoji()); }, [value, onChange]); + useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false)); + return ( = ({ leaveTo="transform opacity-0 scale-95" > -
+
{tabOptions.map((tab) => ( diff --git a/web/components/estimates/single-estimate.tsx b/web/components/estimates/single-estimate.tsx index 3adf986ae..43edfcb2c 100644 --- a/web/components/estimates/single-estimate.tsx +++ b/web/components/estimates/single-estimate.tsx @@ -66,7 +66,7 @@ export const SingleEstimate: React.FC = ({ return ( <> -
+
diff --git a/web/components/icons/index.ts b/web/components/icons/index.ts index d3be7f2a8..bf3e94332 100644 --- a/web/components/icons/index.ts +++ b/web/components/icons/index.ts @@ -83,3 +83,5 @@ export * from "./archive-icon"; export * from "./clock-icon"; export * from "./bell-icon"; export * from "./single-comment-icon"; +export * from "./related-icon"; +export * from "./module-icon"; \ No newline at end of file diff --git a/web/components/icons/module-icon.tsx b/web/components/icons/module-icon.tsx new file mode 100644 index 000000000..dbe58eb53 --- /dev/null +++ b/web/components/icons/module-icon.tsx @@ -0,0 +1,59 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const ModuleIcon: React.FC = ({ + width = "24", + height = "24", + className, + color = "#F15B5B", +}) => ( + + + + + + + +); diff --git a/web/components/icons/related-icon.tsx b/web/components/icons/related-icon.tsx new file mode 100644 index 000000000..3abb4b1c3 --- /dev/null +++ b/web/components/icons/related-icon.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const RelatedIcon: React.FC = ({ + width = "24", + height = "24", + color = "rgb(var(--color-text-200))", + className, +}) => ( + + + + + +); diff --git a/web/components/integration/github/select-repository.tsx b/web/components/integration/github/select-repository.tsx index 9857c0088..b46942e6d 100644 --- a/web/components/integration/github/select-repository.tsx +++ b/web/components/integration/github/select-repository.tsx @@ -66,6 +66,8 @@ export const SelectRepository: React.FC = ({ content:

{truncateText(repo.full_name, characterLimit)}

, })) ?? []; + if (userRepositories.length < 1) return null; + return ( = ({ integration }) => { {projectIntegration ? ( diff --git a/web/components/issues/form.tsx b/web/components/issues/form.tsx index 0bca224df..ae8a01896 100644 --- a/web/components/issues/form.tsx +++ b/web/components/issues/form.tsx @@ -23,14 +23,7 @@ import { import { CreateStateModal } from "components/states"; import { CreateLabelModal } from "components/labels"; // ui -import { - CustomMenu, - Input, - Loader, - PrimaryButton, - SecondaryButton, - ToggleSwitch, -} from "components/ui"; +import { CustomMenu, Input, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui"; import { TipTapEditor } from "components/tiptap"; // icons import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline"; @@ -52,7 +45,7 @@ const defaultValues: Partial = { estimate_point: null, state: "", parent: null, - priority: null, + priority: "none", assignees: [], assignees_list: [], labels: [], diff --git a/web/components/issues/gantt-chart/blocks.tsx b/web/components/issues/gantt-chart/blocks.tsx index 0834e3e79..ef4919780 100644 --- a/web/components/issues/gantt-chart/blocks.tsx +++ b/web/components/issues/gantt-chart/blocks.tsx @@ -11,13 +11,21 @@ import { IIssue } from "types"; export const IssueGanttBlock = ({ data }: { data: IIssue }) => { const router = useRouter(); - const { workspaceSlug } = router.query; + + const openPeekOverview = () => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: data.id }, + }); + }; return (
router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)} + onClick={openPeekOverview} >
{ // rendering issues on gantt sidebar export const IssueGanttSidebarBlock = ({ data }: { data: IIssue }) => { const router = useRouter(); - const { workspaceSlug } = router.query; const duration = findTotalDaysInRange(data?.start_date ?? "", data?.target_date ?? "", true); + const openPeekOverview = () => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: data.id }, + }); + }; + return (
router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)} + onClick={openPeekOverview} >
diff --git a/web/components/issues/gantt-chart/layout.tsx b/web/components/issues/gantt-chart/layout.tsx index a78319a4b..ed4cd3d70 100644 --- a/web/components/issues/gantt-chart/layout.tsx +++ b/web/components/issues/gantt-chart/layout.tsx @@ -8,11 +8,15 @@ import { updateGanttIssue } from "components/gantt-chart/hooks/block-update"; import useProjectDetails from "hooks/use-project-details"; // components import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart"; -import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues"; +import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues"; // types import { IIssue } from "types"; -export const IssueGanttChartView = () => { +type Props = { + disableUserActions: boolean; +}; + +export const IssueGanttChartView: React.FC = ({ disableUserActions }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -29,23 +33,31 @@ export const IssueGanttChartView = () => { const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; return ( -
- - updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) - } - BlockRender={IssueGanttBlock} - SidebarBlockRender={IssueGanttSidebarBlock} - enableBlockLeftResize={isAllowed} - enableBlockRightResize={isAllowed} - enableBlockMove={isAllowed} - enableReorder={displayFilters.order_by === "sort_order" && isAllowed} - bottomSpacing + <> + mutateGanttIssues()} + projectId={projectId?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} /> -
+
+ + updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) + } + BlockRender={IssueGanttBlock} + SidebarBlockRender={IssueGanttSidebarBlock} + enableBlockLeftResize={isAllowed} + enableBlockRightResize={isAllowed} + enableBlockMove={isAllowed} + enableReorder={displayFilters.order_by === "sort_order" && isAllowed} + bottomSpacing + /> +
+ ); }; diff --git a/web/components/issues/my-issues/my-issues-view.tsx b/web/components/issues/my-issues/my-issues-view.tsx index 81a456079..7dc5c8d20 100644 --- a/web/components/issues/my-issues/my-issues-view.tsx +++ b/web/components/issues/my-issues/my-issues-view.tsx @@ -57,7 +57,7 @@ export const MyIssuesView: React.FC = ({ const { user } = useUserAuth(); const { groupedIssues, mutateMyIssues, isEmpty, params } = useMyIssues(workspaceSlug?.toString()); - const { filters, setFilters, displayFilters, setDisplayFilters, properties } = useMyIssuesFilters( + const { filters, setFilters, displayFilters, properties } = useMyIssuesFilters( workspaceSlug?.toString() ); diff --git a/web/components/issues/select/priority.tsx b/web/components/issues/select/priority.tsx index 6a2a07cbd..8624f8cf8 100644 --- a/web/components/issues/select/priority.tsx +++ b/web/components/issues/select/priority.tsx @@ -40,7 +40,7 @@ export const IssuePrioritySelect: React.FC = ({ value, onChange }) => ( - {priority ?? "None"} + {priority}
diff --git a/web/components/issues/sidebar-select/blocked.tsx b/web/components/issues/sidebar-select/blocked.tsx index 02cfd3b16..9554a83ba 100644 --- a/web/components/issues/sidebar-select/blocked.tsx +++ b/web/components/issues/sidebar-select/blocked.tsx @@ -1,11 +1,13 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; - // react-hook-form import { UseFormWatch } from "react-hook-form"; // hooks import useToast from "hooks/use-toast"; +import useUser from "hooks/use-user"; +// services +import issuesService from "services/issues.service"; // components import { ExistingIssuesListModal } from "components/core"; // icons @@ -29,10 +31,11 @@ export const SidebarBlockedSelect: React.FC = ({ }) => { const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); + const { user } = useUser(); const { setToastAlert } = useToast(); const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; const handleClose = () => { setIsBlockedModalOpen(false); @@ -62,21 +65,39 @@ export const SidebarBlockedSelect: React.FC = ({ }, })); - const newBlocked = [...watch("blocked_issues"), ...selectedIssues]; + if (!user) return; + + issuesService + .createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, { + related_list: [ + ...selectedIssues.map((issue) => ({ + issue: issueId as string, + relation_type: "blocked_by" as const, + related_issue_detail: issue.blocked_issue_detail, + related_issue: issue.blocked_issue_detail.id, + })), + ], + }) + .then((response) => { + submitChanges({ + related_issues: [ + ...watch("related_issues")?.filter((i) => i.relation_type !== "blocked_by"), + ...response, + ], + }); + }); - submitChanges({ - blocked_issues: newBlocked, - blocks_list: newBlocked.map((i) => i.blocked_issue_detail?.id ?? ""), - }); handleClose(); }; + const blockedByIssue = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by"); + return ( <> setIsBlockedModalOpen(false)} - searchParams={{ blocker_blocked_by: true, issue_id: issueId }} + searchParams={{ issue_relation: true, issue_id: issueId }} handleOnSubmit={onSubmit} workspaceLevelToggle /> @@ -87,33 +108,42 @@ export const SidebarBlockedSelect: React.FC = ({
- {watch("blocked_issues") && watch("blocked_issues").length > 0 - ? watch("blocked_issues").map((issue) => ( + {blockedByIssue && blockedByIssue.length > 0 + ? blockedByIssue.map((relation) => (
- {`${issue.blocked_issue_detail?.project_detail.identifier}-${issue.blocked_issue_detail?.sequence_id}`} + {`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`}
- {watch("blocker_issues") && watch("blocker_issues").length > 0 - ? watch("blocker_issues").map((issue) => ( + {blockerIssue && blockerIssue.length > 0 + ? blockerIssue.map((relation) => (
- {`${issue.blocker_issue_detail?.project_detail.identifier}-${issue.blocker_issue_detail?.sequence_id}`} + {`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`} +
+ )) + : null} +
+ +
+
+ + ); +}; diff --git a/web/components/issues/sidebar-select/index.ts b/web/components/issues/sidebar-select/index.ts index 5035325fd..8b083841e 100644 --- a/web/components/issues/sidebar-select/index.ts +++ b/web/components/issues/sidebar-select/index.ts @@ -8,3 +8,5 @@ export * from "./module"; export * from "./parent"; export * from "./priority"; export * from "./state"; +export * from "./duplicate"; +export * from "./relates-to"; diff --git a/web/components/issues/sidebar-select/relates-to.tsx b/web/components/issues/sidebar-select/relates-to.tsx new file mode 100644 index 000000000..fb878daee --- /dev/null +++ b/web/components/issues/sidebar-select/relates-to.tsx @@ -0,0 +1,172 @@ +import React, { useState } from "react"; + +import { useRouter } from "next/router"; +// react-hook-form +import { UseFormWatch } from "react-hook-form"; +// hooks +import useToast from "hooks/use-toast"; +import useUser from "hooks/use-user"; +// icons +import { X } from "lucide-react"; +import { BlockerIcon, RelatedIcon } from "components/icons"; +// components +import { ExistingIssuesListModal } from "components/core"; +// services +import issuesService from "services/issues.service"; +// types +import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types"; + +type Props = { + issueId?: string; + submitChanges: (formData: Partial) => void; + watch: UseFormWatch; + disabled?: boolean; +}; + +export const SidebarRelatesSelect: React.FC = (props) => { + const { issueId, submitChanges, watch, disabled = false } = props; + + const [isRelatesToModalOpen, setIsRelatesToModalOpen] = useState(false); + + const { user } = useUser(); + const { setToastAlert } = useToast(); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const handleClose = () => { + setIsRelatesToModalOpen(false); + }; + + const onSubmit = async (data: ISearchIssueResponse[]) => { + if (data.length === 0) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Please select at least one issue.", + }); + + return; + } + + const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({ + blocker_issue_detail: { + id: i.id, + name: i.name, + sequence_id: i.sequence_id, + project_detail: { + id: i.project_id, + identifier: i.project__identifier, + name: i.project__name, + }, + }, + })); + + if (!user) return; + + issuesService + .createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, { + related_list: [ + ...selectedIssues.map((issue) => ({ + issue: issueId as string, + related_issue_detail: issue.blocker_issue_detail, + related_issue: issue.blocker_issue_detail.id, + relation_type: "relates_to" as const, + })), + ], + }) + .then((response) => { + submitChanges({ + related_issues: [...watch("related_issues"), ...(response ?? [])], + }); + }); + + handleClose(); + }; + + const relatedToIssueRelation = [ + ...(watch("related_issues")?.filter((i) => i.relation_type === "relates_to") ?? []), + ...(watch("issue_relations") ?? []) + ?.filter((i) => i.relation_type === "relates_to") + .map((i) => ({ + ...i, + related_issue_detail: i.issue_detail, + related_issue: i.issue_detail?.id, + })), + ]; + + return ( + <> + setIsRelatesToModalOpen(false)} + searchParams={{ issue_relation: true, issue_id: issueId }} + handleOnSubmit={onSubmit} + workspaceLevelToggle + /> +
+
+ +

Relates to

+
+
+
+ {relatedToIssueRelation && relatedToIssueRelation.length > 0 + ? relatedToIssueRelation.map((relation) => ( +
+ + + {`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`} + + +
+ )) + : null} +
+ +
+
+ + ); +}; diff --git a/web/components/issues/sidebar.tsx b/web/components/issues/sidebar.tsx index a33d17705..1f48f7307 100644 --- a/web/components/issues/sidebar.tsx +++ b/web/components/issues/sidebar.tsx @@ -30,6 +30,8 @@ import { SidebarStateSelect, SidebarEstimateSelect, SidebarLabelSelect, + SidebarDuplicateSelect, + SidebarRelatesSelect, } from "components/issues"; // ui import { CustomDatePicker, Icon } from "components/ui"; @@ -76,6 +78,8 @@ type Props = { | "delete" | "all" | "subscribe" + | "duplicate" + | "relates_to" )[]; uneditable?: boolean; }; @@ -464,7 +468,19 @@ export const IssueDetailsSidebar: React.FC = ({ {(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && ( { + mutate( + ISSUE_DETAILS(issueId as string), + (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + ...data, + }; + }, + false + ); + }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} /> @@ -472,7 +488,59 @@ export const IssueDetailsSidebar: React.FC = ({ {(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && ( { + mutate( + ISSUE_DETAILS(issueId as string), + (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + ...data, + }; + }, + false + ); + }} + watch={watchIssue} + disabled={memberRole.isGuest || memberRole.isViewer || uneditable} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("duplicate")) && ( + { + mutate( + ISSUE_DETAILS(issueId as string), + (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + ...data, + }; + }, + false + ); + }} + watch={watchIssue} + disabled={memberRole.isGuest || memberRole.isViewer || uneditable} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("relates_to")) && ( + { + mutate( + ISSUE_DETAILS(issueId as string), + (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + ...data, + }; + }, + false + ); + }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} /> diff --git a/web/components/labels/create-update-label-inline.tsx b/web/components/labels/create-update-label-inline.tsx index 6306d14ca..61064e777 100644 --- a/web/components/labels/create-update-label-inline.tsx +++ b/web/components/labels/create-update-label-inline.tsx @@ -17,7 +17,7 @@ import issuesService from "services/issues.service"; // ui import { Input, PrimaryButton, SecondaryButton } from "components/ui"; // icons -import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import { Component } from "lucide-react"; // types import { IIssueLabels } from "types"; // fetch-keys @@ -132,7 +132,7 @@ export const CreateUpdateLabelInline = forwardRef( return (
( open ? "text-custom-text-100" : "text-custom-text-200" }`} > - -