diff --git a/apiserver/.env.example b/apiserver/.env.example index ace1e07b1..37178b398 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -14,6 +14,11 @@ PGHOST="plane-db" PGDATABASE="plane" DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} +# Oauth variables +GOOGLE_CLIENT_ID="" +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" + # Redis Settings REDIS_HOST="plane-redis" REDIS_PORT="6379" @@ -50,7 +55,6 @@ NGINX_PORT=80 # SignUps ENABLE_SIGNUP="1" - # Enable Email/Password Signup ENABLE_EMAIL_PASSWORD="1" diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 5895a1bfc..eaff8181a 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -30,6 +30,11 @@ class CycleSerializer(BaseSerializer): model = Cycle fields = "__all__" read_only_fields = [ + "id", + "created_at", + "updated_at", + "created_by", + "updated_by", "workspace", "project", "owned_by", diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 2dbdddfc6..ab61ae523 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -1,3 +1,6 @@ +from lxml import html + + # Django imports from django.utils import timezone @@ -21,7 +24,8 @@ from plane.db.models import ( from .base import BaseSerializer from .cycle import CycleSerializer, CycleLiteSerializer from .module import ModuleSerializer, ModuleLiteSerializer - +from .user import UserLiteSerializer +from .state import StateLiteSerializer class IssueSerializer(BaseSerializer): assignees = serializers.ListField( @@ -42,7 +46,6 @@ class IssueSerializer(BaseSerializer): class Meta: model = Issue - fields = "__all__" read_only_fields = [ "id", "workspace", @@ -52,6 +55,10 @@ class IssueSerializer(BaseSerializer): "created_at", "updated_at", ] + exclude = [ + "description", + "description_stripped", + ] def validate(self, data): if ( @@ -60,6 +67,15 @@ class IssueSerializer(BaseSerializer): and data.get("start_date", None) > data.get("target_date", None) ): raise serializers.ValidationError("Start date cannot exceed target date") + + try: + if(data.get("description_html", None) is not None): + parsed = html.fromstring(data["description_html"]) + parsed_str = html.tostring(parsed, encoding='unicode') + data["description_html"] = parsed_str + + except Exception as e: + raise serializers.ValidationError(f"Invalid HTML: {str(e)}") # Validate assignees are from project if data.get("assignees", []): @@ -291,7 +307,6 @@ class IssueCommentSerializer(BaseSerializer): class Meta: model = IssueComment - fields = "__all__" read_only_fields = [ "id", "workspace", @@ -302,6 +317,21 @@ class IssueCommentSerializer(BaseSerializer): "created_at", "updated_at", ] + exclude = [ + "comment_stripped", + "comment_json", + ] + + def validate(self, data): + try: + if(data.get("comment_html", None) is not None): + parsed = html.fromstring(data["comment_html"]) + parsed_str = html.tostring(parsed, encoding='unicode') + data["comment_html"] = parsed_str + + except Exception as e: + raise serializers.ValidationError(f"Invalid HTML: {str(e)}") + return data class IssueActivitySerializer(BaseSerializer): @@ -331,12 +361,23 @@ class ModuleIssueSerializer(BaseSerializer): ] -class IssueExpandSerializer(BaseSerializer): - # Serialize the related cycle. It's a OneToOne relation. - cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True) +class LabelLiteSerializer(BaseSerializer): - # Serialize the related module. It's a OneToOne relation. + class Meta: + model = Label + fields = [ + "id", + "name", + "color", + ] + + +class IssueExpandSerializer(BaseSerializer): + cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True) module = ModuleLiteSerializer(source="issue_module.module", read_only=True) + labels = LabelLiteSerializer(read_only=True, many=True) + assignees = UserLiteSerializer(read_only=True, many=True) + state = StateLiteSerializer(read_only=True) class Meta: model = Issue @@ -349,4 +390,4 @@ class IssueExpandSerializer(BaseSerializer): "updated_by", "created_at", "updated_at", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 932597799..c394a080d 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -21,6 +21,7 @@ class ProjectSerializer(BaseSerializer): fields = "__all__" read_only_fields = [ "id", + 'emoji', "workspace", "created_at", "updated_at", diff --git a/apiserver/plane/api/serializers/state.py b/apiserver/plane/api/serializers/state.py index 4c7f05ab8..9d08193d8 100644 --- a/apiserver/plane/api/serializers/state.py +++ b/apiserver/plane/api/serializers/state.py @@ -16,6 +16,11 @@ class StateSerializer(BaseSerializer): model = State fields = "__all__" read_only_fields = [ + "id", + "created_by", + "updated_by", + "created_at", + "updated_at", "workspace", "project", ] diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index e5a77da93..42b6c3967 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -11,10 +11,6 @@ class UserLiteSerializer(BaseSerializer): "first_name", "last_name", "avatar", - "is_bot", "display_name", ] - read_only_fields = [ - "id", - "is_bot", - ] \ No newline at end of file + read_only_fields = fields \ No newline at end of file diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 679c12964..3d2861778 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -64,7 +64,7 @@ class StateAPIEndpoint(BaseAPIView): ) if state.default: - return Response({"error": "Default state cannot be deleted"}, status=False) + return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST) # Check for any issues in the state issue_exist = Issue.issue_objects.filter(state=state_id).exists() diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index 5c9c69e5c..1b94758e8 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -169,8 +169,8 @@ class ChangePasswordSerializer(serializers.Serializer): Serializer for password change endpoint. """ old_password = serializers.CharField(required=True) - new_password = serializers.CharField(required=True) - confirm_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True, min_length=8) + confirm_password = serializers.CharField(required=True, min_length=8) def validate(self, data): if data.get("old_password") == data.get("new_password"): @@ -187,9 +187,7 @@ class ChangePasswordSerializer(serializers.Serializer): class ResetPasswordSerializer(serializers.Serializer): - model = User - """ Serializer for password change endpoint. """ - new_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True, min_length=8) diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py index 1de511f89..2dc0fa983 100644 --- a/apiserver/plane/app/views/auth_extended.py +++ b/apiserver/plane/app/views/auth_extended.py @@ -105,17 +105,21 @@ class ForgotPasswordEndpoint(BaseAPIView): def post(self, request): email = request.data.get("email") - if User.objects.filter(email=email).exists(): - user = User.objects.get(email=email) - uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) - token = PasswordResetTokenGenerator().make_token(user) + try: + validate_email(email) + except ValidationError: + return Response({"error": "Please enter a valid email"}, status=status.HTTP_400_BAD_REQUEST) + # Get the user + user = User.objects.filter(email=email).first() + if user: + # Get the reset token for user + uidb64, token = get_tokens_for_user(user=user) current_site = request.META.get("HTTP_ORIGIN") - + # send the forgot password email forgot_password.delay( user.first_name, user.email, uidb64, token, current_site ) - return Response( {"message": "Check your email to reset your password"}, status=status.HTTP_200_OK, @@ -130,14 +134,18 @@ class ResetPasswordEndpoint(BaseAPIView): def post(self, request, uidb64, token): try: + # Decode the id from the uidb64 id = smart_str(urlsafe_base64_decode(uidb64)) user = User.objects.get(id=id) + + # check if the token is valid for the user if not PasswordResetTokenGenerator().check_token(user, token): return Response( {"error": "Token is invalid"}, status=status.HTTP_401_UNAUTHORIZED, ) + # Reset the password serializer = ResetPasswordSerializer(data=request.data) if serializer.is_valid(): # set_password also hashes the password that the user will get @@ -145,9 +153,9 @@ class ResetPasswordEndpoint(BaseAPIView): user.is_password_autoset = False user.save() + # Log the user in # Generate access token for the user access_token, refresh_token = get_tokens_for_user(user) - data = { "access_token": access_token, "refresh_token": refresh_token, @@ -166,7 +174,6 @@ class ResetPasswordEndpoint(BaseAPIView): class ChangePasswordEndpoint(BaseAPIView): def post(self, request): serializer = ChangePasswordSerializer(data=request.data) - user = User.objects.get(pk=request.user.id) if serializer.is_valid(): if not user.check_password(serializer.data.get("old_password")): @@ -218,16 +225,15 @@ class EmailCheckEndpoint(BaseAPIView): ] def post(self, request): - # get the email - # Check the instance registration instance = Instance.objects.first() - if instance is None: + if instance is None or not instance.is_setup_done: return Response( {"error": "Instance is not configured"}, status=status.HTTP_400_BAD_REQUEST, ) + # Get the configurations instance_configuration = InstanceConfiguration.objects.values("key", "value") email = request.data.get("email", False) @@ -267,7 +273,7 @@ class EmailCheckEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - + # Create the user with default values user = User.objects.create( email=email, username=uuid.uuid4().hex, @@ -325,7 +331,7 @@ class EmailCheckEndpoint(BaseAPIView): first_time=True, ) # Automatically send the email - return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_400_BAD_REQUEST) + return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) # Existing user else: if type == "magic_code": diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py index 87118b7d5..487cdae48 100644 --- a/apiserver/plane/app/views/authentication.py +++ b/apiserver/plane/app/views/authentication.py @@ -10,14 +10,12 @@ from django.utils import timezone from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.conf import settings -from django.contrib.auth.hashers import make_password # Third party imports from rest_framework.response import Response from rest_framework.permissions import AllowAny from rest_framework import status from rest_framework_simplejwt.tokens import RefreshToken - from sentry_sdk import capture_message # Module imports @@ -33,7 +31,6 @@ from plane.settings.redis import redis_instance from plane.license.models import InstanceConfiguration, Instance from plane.license.utils.instance_value import get_configuration_value from plane.bgtasks.event_tracking_task import auth_events -from plane.bgtasks.magic_link_code_task import magic_link from plane.bgtasks.user_count_task import update_user_instance_user_count @@ -49,6 +46,14 @@ class SignUpEndpoint(BaseAPIView): permission_classes = (AllowAny,) def post(self, request): + # Check if the instance configuration is done + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + instance_configuration = InstanceConfiguration.objects.values("key", "value") email = request.data.get("email", False) @@ -71,6 +76,7 @@ class SignUpEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + # If the sign up is not enabled and the user does not have invite disallow him from creating the account if ( get_configuration_value( instance_configuration, @@ -124,6 +130,14 @@ class SignInEndpoint(BaseAPIView): permission_classes = (AllowAny,) def post(self, request): + # Check if the instance configuration is done + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + email = request.data.get("email", False) password = request.data.get("password", False) @@ -144,14 +158,6 @@ class SignInEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - # Check if the instance setup is done or not - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - # Get the user user = User.objects.filter(email=email).first() @@ -288,6 +294,7 @@ class MagicSignInEndpoint(BaseAPIView): ] def post(self, request): + # Check if the instance configuration is done instance = Instance.objects.first() if instance is None or not instance.is_setup_done: return Response( diff --git a/apiserver/plane/app/views/oauth.py b/apiserver/plane/app/views/oauth.py index 0dd7fbaf0..826ec4b05 100644 --- a/apiserver/plane/app/views/oauth.py +++ b/apiserver/plane/app/views/oauth.py @@ -303,14 +303,6 @@ class OauthEndpoint(BaseAPIView): instance_configuration = InstanceConfiguration.objects.values( "key", "value" ) - # Check if instance is registered or not - instance = Instance.objects.first() - if instance is None and not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - if ( get_configuration_value( instance_configuration, diff --git a/apiserver/plane/app/views/state.py b/apiserver/plane/app/views/state.py index 5867edb68..f7226ba6e 100644 --- a/apiserver/plane/app/views/state.py +++ b/apiserver/plane/app/views/state.py @@ -77,7 +77,7 @@ class StateViewSet(BaseViewSet): ) if state.default: - return Response({"error": "Default state cannot be deleted"}, status=False) + return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST) # Check for any issues in the state issue_exist = Issue.issue_objects.filter(state=pk).exists() diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index e32c2fd68..3d5724605 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -1,7 +1,8 @@ # Python imports import csv import io -import os +import requests +import json # Django imports from django.core.mail import EmailMultiAlternatives, get_connection @@ -17,8 +18,8 @@ from sentry_sdk import capture_exception from plane.db.models import Issue from plane.utils.analytics_plot import build_graph_plot from plane.utils.issue_filters import issue_filters -from plane.license.models import InstanceConfiguration -from plane.license.utils.instance_value import get_configuration_value +from plane.license.models import InstanceConfiguration, Instance +from plane.license.utils.instance_value import get_email_configuration row_mapping = { "state__name": "State", @@ -43,7 +44,7 @@ CYCLE_ID = "issue_cycle__cycle_id" MODULE_ID = "issue_module__module_id" -def send_export_email(email, slug, csv_buffer): +def send_export_email(email, slug, csv_buffer, rows): """Helper function to send export email.""" subject = "Your Export is ready" html_content = render_to_string("emails/exports/analytics.html", {}) @@ -55,47 +56,58 @@ def send_export_email(email, slug, csv_buffer): instance_configuration = InstanceConfiguration.objects.filter( key__startswith="EMAIL_" ).values("key", "value") + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration(instance_configuration=instance_configuration) + + # Send the email if the users don't have smtp configured + if EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD: + # Check the instance registration + instance = Instance.objects.first() + + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + + payload = { + "email": email, + "slug": slug, + "rows": rows, + } + + _ = requests.post( + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/analytics/", + headers=headers, + json=payload, + ) + return + connection = get_connection( - host=get_configuration_value( - instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") - ), - port=int( - get_configuration_value( - instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT") - ) - ), - username=get_configuration_value( - instance_configuration, - "EMAIL_HOST_USER", - os.environ.get("EMAIL_HOST_USER"), - ), - password=get_configuration_value( - instance_configuration, - "EMAIL_HOST_PASSWORD", - os.environ.get("EMAIL_HOST_PASSWORD"), - ), - use_tls=bool( - get_configuration_value( - instance_configuration, - "EMAIL_USE_TLS", - os.environ.get("EMAIL_USE_TLS", "1"), - ) - ), + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=bool(EMAIL_USE_TLS), ) msg = EmailMultiAlternatives( subject=subject, body=text_content, - from_email=get_configuration_value( - instance_configuration, - "EMAIL_FROM", - os.environ.get("EMAIL_FROM", "Team Plane "), - ), + from_email=EMAIL_FROM, to=[email], connection=connection, ) msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue()) msg.send(fail_silently=False) + return def get_assignee_details(slug, filters): @@ -463,8 +475,11 @@ def analytic_export_task(email, data, slug): ) csv_buffer = generate_csv_from_rows(rows) - send_export_email(email, slug, csv_buffer) + send_export_email(email, slug, csv_buffer, rows) + return except Exception as e: + print(e) if settings.DEBUG: print(e) capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index 5348a05da..33cd40dc8 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -40,13 +40,10 @@ def forgot_password(first_name, email, uidb64, token, current_site): ) = get_email_configuration(instance_configuration=instance_configuration) # Send the email if the users don't have smtp configured - if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD: + if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD): # Check the instance registration instance = Instance.objects.first() - # send the emails through control center - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) - # headers headers = { "Content-Type": "application/json", @@ -61,7 +58,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): } _ = requests.post( - f"{license_engine_base_url}/api/instances/users/forgot-password/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/forgot-password/", headers=headers, data=json.dumps(payload), ) diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 2caec2b60..a152b4c7f 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -21,7 +21,6 @@ from plane.license.utils.instance_value import get_email_configuration @shared_task def magic_link(email, key, token, current_site): try: - instance_configuration = InstanceConfiguration.objects.filter( key__startswith="EMAIL_" ).values("key", "value") @@ -36,13 +35,10 @@ def magic_link(email, key, token, current_site): ) = get_email_configuration(instance_configuration=instance_configuration) # Send the email if the users don't have smtp configured - if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD: + if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD): # Check the instance registration instance = Instance.objects.first() - # send the emails through control center - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) - headers = { "Content-Type": "application/json", "x-instance-id": instance.instance_id, @@ -55,7 +51,7 @@ def magic_link(email, key, token, current_site): } _ = requests.post( - f"{license_engine_base_url}/api/instances/users/magic-code/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/magic-code/", headers=headers, data=json.dumps(payload), ) diff --git a/apiserver/plane/bgtasks/user_count_task.py b/apiserver/plane/bgtasks/user_count_task.py index f93c3364e..dd8b19e7d 100644 --- a/apiserver/plane/bgtasks/user_count_task.py +++ b/apiserver/plane/bgtasks/user_count_task.py @@ -32,13 +32,9 @@ def update_user_instance_user_count(): "x-api-key": instance.api_key, } - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL") - if not license_engine_base_url: - raise Exception("License Engine base url is required") - # Update the license engine _ = requests.post( - f"{license_engine_base_url}/api/instances/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", headers=headers, data=json.dumps(payload), ) diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index f5ee96256..3681f002d 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -109,7 +109,7 @@ def webhook_task(self, webhook, slug, event, event_data, action): if webhook.secret_key: hmac_signature = hmac.new( webhook.secret_key.encode("utf-8"), - json.dumps(payload, sort_keys=True).encode("utf-8"), + json.dumps(payload).encode("utf-8"), hashlib.sha256, ) signature = hmac_signature.hexdigest() diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 2cbfdaff7..fe8708ed7 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -51,15 +51,10 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): ) = get_email_configuration(instance_configuration=instance_configuration) # Send the email if the users don't have smtp configured - if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD: + if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD): # Check the instance registration instance = Instance.objects.first() - # send the emails through control center - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) - if not license_engine_base_url: - raise Exception("License engine base url is required") - headers = { "Content-Type": "application/json", "x-instance-id": instance.instance_id, @@ -73,7 +68,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): "email": email, } _ = requests.post( - f"{license_engine_base_url}/api/instances/users/workspace-invitation/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/workspace-invitation/", headers=headers, data=json.dumps(payload), ) diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 7edc50b27..7dbc5e4b4 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -11,6 +11,7 @@ from django.utils import timezone from django.contrib.auth.hashers import make_password from django.core.validators import validate_email from django.core.exceptions import ValidationError +from django.conf import settings # Third party imports from rest_framework import status @@ -34,7 +35,6 @@ from plane.db.models import User from plane.license.utils.encryption import encrypt_data from plane.settings.redis import redis_instance from plane.bgtasks.magic_link_code_task import magic_link -from plane.license.utils.instance_value import get_configuration_value class InstanceEndpoint(BaseAPIView): @@ -57,25 +57,17 @@ class InstanceEndpoint(BaseAPIView): # Load JSON content from the file data = json.load(file) - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL") - - if not license_engine_base_url: - raise Response( - {"error": "LICENSE_ENGINE_BASE_URL is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - headers = {"Content-Type": "application/json"} payload = { - "instance_key": os.environ.get("INSTANCE_KEY"), + "instance_key":settings.INSTANCE_KEY, "version": data.get("version", 0.1), "machine_signature": os.environ.get("MACHINE_SIGNATURE"), "user_count": User.objects.filter(is_bot=False).count(), } response = requests.post( - f"{license_engine_base_url}/api/instances/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", headers=headers, data=json.dumps(payload), ) @@ -130,6 +122,24 @@ class InstanceEndpoint(BaseAPIView): serializer = InstanceSerializer(instance, data=request.data, partial=True) if serializer.is_valid(): serializer.save() + # Save the user in control center + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + # Update instance settings in the license engine + _ = requests.patch( + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", + headers=headers, + data=json.dumps( + { + "is_support_required": serializer.data["is_support_required"], + "is_telemetry_enabled": serializer.data["is_telemetry_enabled"], + "version": serializer.data["version"], + } + ), + ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -251,7 +261,6 @@ class AdminMagicSignInGenerateEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - if not email: return Response( {"error": "Please provide a valid email address"}, @@ -409,13 +418,6 @@ class AdminSetUserPasswordEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) - if not license_engine_base_url: - return Response( - {"error": "License engine base url is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - # Save the user in control center headers = { "Content-Type": "application/json", @@ -423,14 +425,14 @@ class AdminSetUserPasswordEndpoint(BaseAPIView): "x-api-key": instance.api_key, } _ = requests.patch( - f"{license_engine_base_url}/api/instances/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", headers=headers, data=json.dumps({"is_setup_done": True}), ) # Also register the user as admin _ = requests.post( - f"{license_engine_base_url}/api/instances/users/register/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/register/", headers=headers, data=json.dumps( { @@ -472,24 +474,20 @@ class SignUpScreenVisitedEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) - - if not license_engine_base_url: - return Response( - {"error": "License engine base url is required"}, - status=status.HTTP_400_BAD_REQUEST, + if not instance.is_signup_screen_visited: + instance.is_signup_screen_visited = True + instance.save() + # set the headers + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + # create the payload + payload = {"is_signup_screen_visited": True} + _ = requests.patch( + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", + headers=headers, + data=json.dumps(payload), ) - - headers = { - "Content-Type": "application/json", - "x-instance-id": instance.instance_id, - "x-api-key": instance.api_key, - } - - payload = {"is_signup_screen_visited": True} - response = requests.patch( - f"{license_engine_base_url}/api/instances/", - headers=headers, - data=json.dumps(payload), - ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index b486ecb0b..0970d8093 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -6,6 +6,7 @@ import requests # Django imports from django.core.management.base import BaseCommand, CommandError from django.utils import timezone +from django.conf import settings # Module imports from plane.license.models import Instance @@ -30,31 +31,22 @@ class Command(BaseCommand): data = json.load(file) machine_signature = options.get("machine_signature", False) - instance_key = os.environ.get("INSTANCE_KEY", False) - - # Raise an exception if the admin email is not provided - if not instance_key: - raise CommandError("INSTANCE_KEY is required") + if not machine_signature: raise CommandError("Machine signature is required") - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL") - - if not license_engine_base_url: - raise CommandError("LICENSE_ENGINE_BASE_URL is required") - headers = {"Content-Type": "application/json"} payload = { - "instance_key": instance_key, + "instance_key": settings.INSTANCE_KEY, "version": data.get("version", 0.1), "machine_signature": machine_signature, "user_count": User.objects.filter(is_bot=False).count(), } response = requests.post( - f"{license_engine_base_url}/api/instances/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", headers=headers, data=json.dumps(payload), ) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index ba2c941ef..fff6b9e90 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -290,7 +290,7 @@ CELERY_IMPORTS = ( # Sentry Settings # Enable Sentry Settings -if bool(os.environ.get("SENTRY_DSN", False)): +if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get("SENTRY_DSN").startswith("https://"): sentry_sdk.init( dsn=os.environ.get("SENTRY_DSN", ""), integrations=[ @@ -324,4 +324,10 @@ USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 # Posthog settings POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False) -POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False) \ No newline at end of file +POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False) + +# License engine base url +LICENSE_ENGINE_BASE_URL = os.environ.get("LICENSE_ENGINE_BASE_URL", "https://control-center.plane.so") + +# instance key +INSTANCE_KEY = os.environ.get("INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3") diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 75437fbee..2da24092a 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -63,7 +63,7 @@ def date_filter(filter, date_term, queries): duration=int(digit), subsequent=date_query[1], term=term, - date_filter="created_at__date", + date_filter=date_term, offset=date_query[2], ) else: diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 5342da85d..b6059bcd5 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -38,3 +38,4 @@ beautifulsoup4==4.12.2 dj-database-url==2.1.0 posthog==3.0.2 cryptography==41.0.5 +lxml==4.9.3 diff --git a/apiserver/templates/emails/auth/magic_signin.html b/apiserver/templates/emails/auth/magic_signin.html index 4e47487bd..ba469db7e 100644 --- a/apiserver/templates/emails/auth/magic_signin.html +++ b/apiserver/templates/emails/auth/magic_signin.html @@ -149,20 +149,20 @@ padding-top: 10px !important; } .r13-o { - border-bottom-color: #efefef !important; + border-bottom-color: #d9e4ff !important; border-bottom-width: 1px !important; - border-left-color: #efefef !important; + border-left-color: #d9e4ff !important; border-left-width: 1px !important; - border-right-color: #efefef !important; + border-right-color: #d9e4ff !important; border-right-width: 1px !important; border-style: solid !important; - border-top-color: #efefef !important; + border-top-color: #d9e4ff !important; border-top-width: 1px !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r14-i { - background-color: #e3e6f1 !important; + background-color: #ecf1ff !important; padding-bottom: 10px !important; padding-left: 10px !important; padding-right: 10px !important; @@ -225,16 +225,11 @@ padding-top: 5px !important; } .r24-o { - border-style: solid !important; - margin-right: 8px !important; - width: 32px !important; - } - .r25-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important; } - .r26-i { + .r25-i { padding-bottom: 0px !important; padding-top: 5px !important; text-align: center !important; @@ -664,17 +659,17 @@ width="100%" class="r13-o" style=" - background-color: #e3e6f1; - border-bottom-color: #efefef; + background-color: #ecf1ff; + border-bottom-color: #d9e4ff; border-bottom-width: 1px; border-collapse: separate; - border-left-color: #efefef; + border-left-color: #d9e4ff; border-left-width: 1px; border-radius: 5px; - border-right-color: #efefef; + border-right-color: #d9e4ff; border-right-width: 1px; border-style: solid; - border-top-color: #efefef; + border-top-color: #d9e4ff; border-top-width: 1px; table-layout: fixed; width: 100%; @@ -690,7 +685,7 @@ font-family: georgia, serif; font-size: 16px; word-break: break-word; - background-color: #e3e6f1; + background-color: #ecf1ff; border-radius: 4px; line-height: 3; padding-bottom: 10px; @@ -714,10 +709,10 @@

Please copy and paste this on the screen where you @@ -1251,13 +1246,14 @@ role="presentation" width="100%" class="r22-o" - class="r24-o" style=" + table-layout: fixed; + width: 100%; + " > - - Dear {{username}},
- Your requested Issue's data has been successfully exported from Plane. The export includes all relevant information about issues you requested from your selected projects.
- Please find the attachment and download the CSV file. If you have any questions or need further assistance, please don't hesitate to contact our support team at engineering@plane.so. We're here to help!
- Thank you for using Plane. We hope this export will aid you in effectively managing your projects.
- Regards, - Team Plane - diff --git a/deploy/coolify/coolify-docker-compose.yml b/deploy/coolify/coolify-docker-compose.yml index 6dd361d34..2a21c61a8 100644 --- a/deploy/coolify/coolify-docker-compose.yml +++ b/deploy/coolify/coolify-docker-compose.yml @@ -71,7 +71,6 @@ services: - ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - WEB_URL=$SERVICE_FQDN_PLANE_8082 - - LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"} depends_on: - plane-db - plane-redis @@ -117,7 +116,6 @@ services: - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - - LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"} depends_on: - api - plane-db @@ -164,7 +162,6 @@ services: - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - - LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"} depends_on: - api - plane-db diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 7eec3fd5a..ba0c28827 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -5,15 +5,16 @@ x-app-env : &app-env - NGINX_PORT=${NGINX_PORT:-80} - WEB_URL=${WEB_URL:-http://localhost} - DEBUG=${DEBUG:-0} - - NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} - - NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} + - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.production} # deprecated + - NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} # deprecated + - NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} # deprecated - SENTRY_DSN=${SENTRY_DSN:-""} + - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-""} + - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-""} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} - DOCKERIZED=${DOCKERIZED:-1} # deprecated - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""} - - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} - - ADMIN_EMAIL=${ADMIN_EMAIL:-""} - - LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-""} # Gunicorn Workers - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} #DB SETTINGS @@ -28,12 +29,12 @@ x-app-env : &app-env - REDIS_HOST=${REDIS_HOST:-plane-redis} - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/} - # EMAIL SETTINGS + # EMAIL SETTINGS - Deprecated can be configured through admin panel - EMAIL_HOST=${EMAIL_HOST:-""} - EMAIL_HOST_USER=${EMAIL_HOST_USER:-""} - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""} - EMAIL_PORT=${EMAIL_PORT:-587} - - EMAIL_FROM=${EMAIL_FROM:-"Team Plane <team@mailer.plane.so>"} + - EMAIL_FROM=${EMAIL_FROM:-"Team Plane "} - EMAIL_USE_TLS=${EMAIL_USE_TLS:-1} - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} @@ -42,10 +43,11 @@ x-app-env : &app-env - OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1} - OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"} - GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"} - # LOGIN/SIGNUP SETTINGS + # LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1} - ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0} + # Application secret - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} # DATA STORE SETTINGS - USE_MINIO=${USE_MINIO:-1} diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index 10ca42879..6be9ca2f4 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -10,10 +10,12 @@ DEBUG=0 NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces SENTRY_DSN="" +SENTRY_ENVIRONMENT="production" +GOOGLE_CLIENT_ID="" +GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET="" DOCKERIZED=1 # deprecated CORS_ALLOWED_ORIGINS="http://localhost" -SENTRY_ENVIRONMENT="production" #DB SETTINGS PGHOST=plane-db @@ -34,7 +36,7 @@ EMAIL_HOST="" EMAIL_HOST_USER="" EMAIL_HOST_PASSWORD="" EMAIL_PORT=587 -EMAIL_FROM="Team Plane <team@mailer.plane.so>" +EMAIL_FROM="Team Plane " EMAIL_USE_TLS=1 EMAIL_USE_SSL=0 @@ -63,9 +65,3 @@ FILE_SIZE_LIMIT=5242880 # Gunicorn Workers GUNICORN_WORKERS=2 - -# Admin Email -ADMIN_EMAIL="" - -# License Engine url -LICENSE_ENGINE_BASE_URL="" \ No newline at end of file diff --git a/space/components/accounts/email-code-form.tsx b/space/components/accounts/email-code-form.tsx index b760ccfbb..5b1a15434 100644 --- a/space/components/accounts/email-code-form.tsx +++ b/space/components/accounts/email-code-form.tsx @@ -11,7 +11,7 @@ import useToast from "hooks/use-toast"; import useTimer from "hooks/use-timer"; // ui -import { Input, PrimaryButton } from "components/ui"; +import { Button, Input } from "@plane/ui"; // types type EmailCodeFormValues = { @@ -133,7 +133,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => { id="email" type="email" placeholder="Enter your email address..." - className="border-custom-border-300 h-[46px]" + className="border-custom-border-300 h-[46px] w-full" {...register("email", { required: "Email address is required", validate: (value) => @@ -154,7 +154,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => { required: "Code is required", })} placeholder="Enter code..." - className="border-custom-border-300 h-[46px]" + className="border-custom-border-300 h-[46px] w-full" /> {errors.token &&

{errors.token.message}
} ) : ( - { handleSubmit(onSubmit)().then(() => { setResendCodeTimer(30); @@ -208,7 +210,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => { loading={isSubmitting} > {isSubmitting ? "Sending code..." : "Send sign in code"} - + )} diff --git a/space/components/accounts/email-password-form.tsx b/space/components/accounts/email-password-form.tsx index 775f1a3c2..b07a26956 100644 --- a/space/components/accounts/email-password-form.tsx +++ b/space/components/accounts/email-password-form.tsx @@ -5,7 +5,8 @@ import { useForm } from "react-hook-form"; // components import { EmailResetPasswordForm } from "./email-reset-password-form"; // ui -import { Input, PrimaryButton } from "components/ui"; +import { Button, Input } from "@plane/ui"; + // types type EmailPasswordFormValues = { email: string; @@ -58,7 +59,7 @@ export const EmailPasswordForm: React.FC = ({ onSubmit }) => { ) || "Email address is not valid", })} placeholder="Enter your email address..." - className="border-custom-border-300 h-[46px]" + className="border-custom-border-300 h-[46px] w-full" /> {errors.email &&
{errors.email.message}
} @@ -70,7 +71,7 @@ export const EmailPasswordForm: React.FC = ({ onSubmit }) => { required: "Password is required", })} placeholder="Enter your password..." - className="border-custom-border-300 h-[46px]" + className="border-custom-border-300 h-[46px] w-full" /> {errors.password &&
{errors.password.message}
} @@ -92,14 +93,16 @@ export const EmailPasswordForm: React.FC = ({ onSubmit }) => { )}
- {isSignUpPage ? (isSubmitting ? "Signing up..." : "Sign up") : isSubmitting ? "Signing in..." : "Sign in"} - + {!isSignUpPage && ( diff --git a/space/components/accounts/email-reset-password-form.tsx b/space/components/accounts/email-reset-password-form.tsx index e7752a00f..dc4c32775 100644 --- a/space/components/accounts/email-reset-password-form.tsx +++ b/space/components/accounts/email-reset-password-form.tsx @@ -1,8 +1,7 @@ import React from "react"; import { useForm } from "react-hook-form"; // ui -import { Input } from "components/ui"; -import { Button } from "@plane/ui"; +import { Button, Input } from "@plane/ui"; // types type Props = { setIsResettingPassword: React.Dispatch>; @@ -66,15 +65,15 @@ export const EmailResetPasswordForm: React.FC = ({ setIsResettingPassword ) || "Email address is not valid", })} placeholder="Enter registered email address.." - className="h-[46px] border-custom-border-300" + className="h-[46px] border-custom-border-300 w-full" /> {errors.email &&
{errors.email.message}
}
- -
diff --git a/space/components/accounts/onboarding-form.tsx b/space/components/accounts/onboarding-form.tsx index c3cb972b2..e372ac1e5 100644 --- a/space/components/accounts/onboarding-form.tsx +++ b/space/components/accounts/onboarding-form.tsx @@ -13,7 +13,7 @@ import useToast from "hooks/use-toast"; // services import UserService from "services/user.service"; // ui -import { Input, PrimaryButton } from "components/ui"; +import { Button, Input } from "@plane/ui"; const defaultValues = { first_name: "", @@ -173,9 +173,9 @@ export const OnBoardingForm: React.FC = observer(({ user }) => { - + ); }); diff --git a/space/components/issues/board-views/kanban/block.tsx b/space/components/issues/board-views/kanban/block.tsx index e44f1dba0..34e4cb3f1 100644 --- a/space/components/issues/board-views/kanban/block.tsx +++ b/space/components/issues/board-views/kanban/block.tsx @@ -13,7 +13,7 @@ import { IIssue } from "types/issue"; import { RootStore } from "store/root"; import { useRouter } from "next/router"; -export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => { +export const IssueKanBanBlock = observer(({ issue }: { issue: IIssue }) => { const { project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore(); // router diff --git a/space/components/issues/board-views/kanban/header.tsx b/space/components/issues/board-views/kanban/header.tsx index 8f2f28496..488d94b59 100644 --- a/space/components/issues/board-views/kanban/header.tsx +++ b/space/components/issues/board-views/kanban/header.tsx @@ -10,7 +10,7 @@ import { StateGroupIcon } from "@plane/ui"; import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; -export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { +export const IssueKanBanHeader = observer(({ state }: { state: IIssueState }) => { const store: RootStore = useMobxStore(); const stateGroup = issueGroupFilter(state.group); diff --git a/space/components/issues/board-views/kanban/index.tsx b/space/components/issues/board-views/kanban/index.tsx index b45b037d2..cc00f931e 100644 --- a/space/components/issues/board-views/kanban/index.tsx +++ b/space/components/issues/board-views/kanban/index.tsx @@ -3,8 +3,8 @@ // mobx react lite import { observer } from "mobx-react-lite"; // components -import { IssueListHeader } from "components/issues/board-views/kanban/header"; -import { IssueListBlock } from "components/issues/board-views/kanban/block"; +import { IssueKanBanHeader } from "components/issues/board-views/kanban/header"; +import { IssueKanBanBlock } from "components/issues/board-views/kanban/block"; // ui import { Icon } from "components/ui"; // interfaces @@ -23,14 +23,14 @@ export const IssueKanbanView = observer(() => { store?.issue?.states.map((_state: IIssueState) => (
- +
{store.issue.getFilteredIssuesByState(_state.id) && store.issue.getFilteredIssuesByState(_state.id).length > 0 ? (
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( - + ))}
) : ( diff --git a/space/components/issues/filters-render/index.tsx b/space/components/issues/filters-render/index.tsx deleted file mode 100644 index d797d1506..000000000 --- a/space/components/issues/filters-render/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useRouter } from "next/router"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// components -import IssueStateFilter from "./state"; -import IssueLabelFilter from "./label"; -import IssuePriorityFilter from "./priority"; -// mobx hook -import { useMobxStore } from "lib/mobx/store-provider"; -import { RootStore } from "store/root"; - -const IssueFilter = observer(() => { - const store: RootStore = useMobxStore(); - - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const clearAllFilters = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "all", - // removeAll: true, - // }) - // ); - }; - - // if (store.issue.getIfFiltersIsEmpty()) return null; - - return ( -
-
- {/* state */} - {/* {store.issue.checkIfFilterExistsForKey("state") && } */} - {/* labels */} - {/* {store.issue.checkIfFilterExistsForKey("label") && } */} - {/* priority */} - {/* {store.issue.checkIfFilterExistsForKey("priority") && } */} - {/* clear all filters */} -
-
Clear all filters
-
- close -
-
-
-
- ); -}); - -export default IssueFilter; diff --git a/space/components/issues/filters-render/label/filter-label-block.tsx b/space/components/issues/filters-render/label/filter-label-block.tsx deleted file mode 100644 index a54fb65e4..000000000 --- a/space/components/issues/filters-render/label/filter-label-block.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useRouter } from "next/router"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// mobx hook -import { useMobxStore } from "lib/mobx/store-provider"; -// interfaces -import { IIssueLabel } from "types/issue"; - -export const RenderIssueLabel = observer(({ label }: { label: IIssueLabel }) => { - const store = useMobxStore(); - - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const removeLabelFromFilter = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "label", - // value: label?.id, - // }) - // ); - }; - - return ( -
-
- -
{label?.name}
-
- close -
-
- ); -}); diff --git a/space/components/issues/filters-render/label/index.tsx b/space/components/issues/filters-render/label/index.tsx deleted file mode 100644 index 1d9a4f990..000000000 --- a/space/components/issues/filters-render/label/index.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useRouter } from "next/router"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// components -import { RenderIssueLabel } from "./filter-label-block"; -// interfaces -import { IIssueLabel } from "types/issue"; -// mobx hook -import { useMobxStore } from "lib/mobx/store-provider"; -import { RootStore } from "store/root"; - -const IssueLabelFilter = observer(() => { - const store: RootStore = useMobxStore(); - - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const clearLabelFilters = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "label", - // removeAll: true, - // }) - // ); - }; - - return ( - <> -
-
Labels
-
- {/* {store?.issue?.labels && - store?.issue?.labels.map( - (_label: IIssueLabel, _index: number) => - store.issue.getUserSelectedFilter("label", _label.id) && ( - - ) - )} */} -
-
- close -
-
- - ); -}); - -export default IssueLabelFilter; diff --git a/space/components/issues/filters-render/priority/filter-priority-block.tsx b/space/components/issues/filters-render/priority/filter-priority-block.tsx deleted file mode 100644 index 5fd1ef1a7..000000000 --- a/space/components/issues/filters-render/priority/filter-priority-block.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useRouter } from "next/router"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// mobx hook -import { useMobxStore } from "lib/mobx/store-provider"; -// interfaces -import { IIssuePriorityFilters } from "types/issue"; - -export const RenderIssuePriority = observer(({ priority }: { priority: IIssuePriorityFilters }) => { - const store = useMobxStore(); - - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const removePriorityFromFilter = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "priority", - // value: priority?.key, - // }) - // ); - }; - - return ( -
-
- {priority?.icon} -
-
{priority?.title}
-
- close -
-
- ); -}); diff --git a/space/components/issues/filters-render/priority/index.tsx b/space/components/issues/filters-render/priority/index.tsx deleted file mode 100644 index 100ba1761..000000000 --- a/space/components/issues/filters-render/priority/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useRouter } from "next/router"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// mobx hook -import { useMobxStore } from "lib/mobx/store-provider"; -// components -import { RenderIssuePriority } from "./filter-priority-block"; -// interfaces -import { IIssuePriorityFilters } from "types/issue"; -// constants -import { issuePriorityFilters } from "constants/data"; - -const IssuePriorityFilter = observer(() => { - const store = useMobxStore(); - - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const clearPriorityFilters = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "priority", - // removeAll: true, - // }) - // ); - }; - - return ( - <> -
-
Priority
-
- {/* {issuePriorityFilters.map( - (_priority: IIssuePriorityFilters, _index: number) => - store.issue.getUserSelectedFilter("priority", _priority.key) && ( - - ) - )} */} -
-
{ - clearPriorityFilters(); - }} - > - close -
-
- - ); -}); - -export default IssuePriorityFilter; diff --git a/space/components/issues/filters-render/state/filter-state-block.tsx b/space/components/issues/filters-render/state/filter-state-block.tsx deleted file mode 100644 index b9c8ed4ec..000000000 --- a/space/components/issues/filters-render/state/filter-state-block.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { observer } from "mobx-react-lite"; -// interfaces -import { IIssueState } from "types/issue"; -// constants -import { issueGroupFilter } from "constants/data"; - -export const RenderIssueState = observer(({ state }: { state: IIssueState }) => { - const stateGroup = issueGroupFilter(state.group); - - const removeStateFromFilter = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "state", - // value: state?.id, - // }) - // ); - }; - - if (stateGroup === null) return <>; - return ( -
-
- {/* */} -
-
{state?.name}
-
- close -
-
- ); -}); diff --git a/space/components/issues/filters-render/state/index.tsx b/space/components/issues/filters-render/state/index.tsx deleted file mode 100644 index 0198c5215..000000000 --- a/space/components/issues/filters-render/state/index.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useRouter } from "next/router"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// components -import { RenderIssueState } from "./filter-state-block"; -// interfaces -import { IIssueState } from "types/issue"; -// mobx hook -import { useMobxStore } from "lib/mobx/store-provider"; -import { RootStore } from "store/root"; - -const IssueStateFilter = observer(() => { - const store: RootStore = useMobxStore(); - - const router = useRouter(); - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const clearStateFilters = () => { - // router.replace( - // store.issue.getURLDefinition(workspace_slug, project_slug, { - // key: "state", - // removeAll: true, - // }) - // ); - }; - - return ( - <> -
-
State
-
- {/* {store?.issue?.states && - store?.issue?.states.map( - (_state: IIssueState, _index: number) => - store.issue.getUserSelectedFilter("state", _state.id) && ( - - ) - )} */} -
-
- close -
-
- - ); -}); - -export default IssueStateFilter; diff --git a/space/components/issues/filters/applied-filters/filters-list.tsx b/space/components/issues/filters/applied-filters/filters-list.tsx new file mode 100644 index 000000000..898898232 --- /dev/null +++ b/space/components/issues/filters/applied-filters/filters-list.tsx @@ -0,0 +1,80 @@ +// components +import { AppliedLabelsFilters } from "./label"; +import { AppliedPriorityFilters } from "./priority"; +import { AppliedStateFilters } from "./state"; +// icons +import { X } from "lucide-react"; +// helpers +import { IIssueFilterOptions } from "store/issues/types"; +import { IIssueLabel, IIssueState } from "types/issue"; +// types + +type Props = { + appliedFilters: IIssueFilterOptions; + handleRemoveAllFilters: () => void; + handleRemoveFilter: (key: keyof IIssueFilterOptions, value: string | null) => void; + labels?: IIssueLabel[] | undefined; + states?: IIssueState[] | undefined; +}; + +export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); + +export const AppliedFiltersList: React.FC = (props) => { + const { appliedFilters, handleRemoveAllFilters, handleRemoveFilter, labels, states } = props; + + return ( +
+ {Object.entries(appliedFilters).map(([key, value]) => { + const filterKey = key as keyof IIssueFilterOptions; + + if (!value) return; + + return ( +
+ {replaceUnderscoreIfSnakeCase(filterKey)} +
+ {filterKey === "priority" && ( + handleRemoveFilter("priority", val)} values={value} /> + )} + + {filterKey === "labels" && labels && ( + handleRemoveFilter("labels", val)} + labels={labels} + values={value} + /> + )} + + {filterKey === "state" && states && ( + handleRemoveFilter("state", val)} + states={states} + values={value} + /> + )} + + +
+
+ ); + })} + +
+ ); +}; diff --git a/space/components/issues/filters/applied-filters/label.tsx b/space/components/issues/filters/applied-filters/label.tsx new file mode 100644 index 000000000..ecf824210 --- /dev/null +++ b/space/components/issues/filters/applied-filters/label.tsx @@ -0,0 +1,42 @@ +import { X } from "lucide-react"; +// types +import { IIssueLabel } from "types/issue"; + +type Props = { + handleRemove: (val: string) => void; + labels: IIssueLabel[] | undefined; + values: string[]; +}; + +export const AppliedLabelsFilters: React.FC = (props) => { + const { handleRemove, labels, values } = props; + + return ( + <> + {values.map((labelId) => { + const labelDetails = labels?.find((l) => l.id === labelId); + + if (!labelDetails) return null; + + return ( +
+ + {labelDetails.name} + +
+ ); + })} + + ); +}; diff --git a/space/components/issues/filters/applied-filters/priority.tsx b/space/components/issues/filters/applied-filters/priority.tsx new file mode 100644 index 000000000..f051abf2d --- /dev/null +++ b/space/components/issues/filters/applied-filters/priority.tsx @@ -0,0 +1,31 @@ +import { PriorityIcon } from "@plane/ui"; +import { X } from "lucide-react"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; +}; + +export const AppliedPriorityFilters: React.FC = (props) => { + const { handleRemove, values } = props; + + return ( + <> + {values && + values.length > 0 && + values.map((priority) => ( +
+ + {priority} + +
+ ))} + + ); +}; diff --git a/space/components/issues/filters/applied-filters/root.tsx b/space/components/issues/filters/applied-filters/root.tsx new file mode 100644 index 000000000..3f77dcc06 --- /dev/null +++ b/space/components/issues/filters/applied-filters/root.tsx @@ -0,0 +1,90 @@ +import { FC, useCallback } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// components +import { AppliedFiltersList } from "./filters-list"; +// store +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; +import { IIssueFilterOptions } from "store/issues/types"; + +export const IssueAppliedFilters: FC = observer(() => { + const router = useRouter(); + const { workspace_slug: workspaceSlug, project_slug: projectId } = router.query as { + workspace_slug: string; + project_slug: string; + }; + + const { + issuesFilter: { issueFilters, updateFilters }, + issue: { states, labels }, + project: { activeBoard }, + }: RootStore = useMobxStore(); + + const userFilters = issueFilters?.filters || {}; + + const appliedFilters: IIssueFilterOptions = {}; + Object.entries(userFilters).forEach(([key, value]) => { + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + appliedFilters[key as keyof IIssueFilterOptions] = value; + }); + + const updateRouteParams = useCallback( + (key: keyof IIssueFilterOptions | null, value: string[] | null, clearFields: boolean = false) => { + const state = key === "state" ? value || [] : issueFilters?.filters?.state ?? []; + const priority = key === "priority" ? value || [] : issueFilters?.filters?.priority ?? []; + const labels = key === "labels" ? value || [] : issueFilters?.filters?.labels ?? []; + + let params: any = { board: activeBoard || "list" }; + if (!clearFields) { + if (priority.length > 0) params = { ...params, priorities: priority.join(",") }; + if (state.length > 0) params = { ...params, states: state.join(",") }; + if (labels.length > 0) params = { ...params, labels: labels.join(",") }; + } + + router.push({ pathname: `/${workspaceSlug}/${projectId}`, query: { ...params } }, undefined, { shallow: true }); + }, + [workspaceSlug, projectId, activeBoard, issueFilters, router] + ); + + const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => { + if (!projectId) return; + if (!value) { + updateFilters(projectId, { [key]: null }); + return; + } + + let newValues = issueFilters?.filters?.[key] ?? []; + newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId, { [key]: newValues }); + updateRouteParams(key, newValues); + }; + + const handleRemoveAllFilters = () => { + if (!projectId) return; + + const newFilters: IIssueFilterOptions = {}; + Object.keys(userFilters).forEach((key) => { + newFilters[key as keyof IIssueFilterOptions] = null; + }); + + updateFilters(projectId, { ...newFilters }); + updateRouteParams(null, null, true); + }; + + if (Object.keys(appliedFilters).length === 0) return null; + + return ( +
+ +
+ ); +}); diff --git a/space/components/issues/filters/applied-filters/state.tsx b/space/components/issues/filters/applied-filters/state.tsx new file mode 100644 index 000000000..f238197b8 --- /dev/null +++ b/space/components/issues/filters/applied-filters/state.tsx @@ -0,0 +1,39 @@ +import { X } from "lucide-react"; +import { StateGroupIcon } from "@plane/ui"; +// icons +import { IIssueState } from "types/issue"; +// types + +type Props = { + handleRemove: (val: string) => void; + states: IIssueState[]; + values: string[]; +}; + +export const AppliedStateFilters: React.FC = (props) => { + const { handleRemove, states, values } = props; + + return ( + <> + {values.map((stateId) => { + const stateDetails = states?.find((s) => s.id === stateId); + + if (!stateDetails) return null; + + return ( +
+ + {stateDetails.name} + +
+ ); + })} + + ); +}; diff --git a/space/components/issues/filters/helpers/dropdown.tsx b/space/components/issues/filters/helpers/dropdown.tsx new file mode 100644 index 000000000..0f93b75c9 --- /dev/null +++ b/space/components/issues/filters/helpers/dropdown.tsx @@ -0,0 +1,72 @@ +import React, { Fragment, useState } from "react"; +import { usePopper } from "react-popper"; +import { Popover, Transition } from "@headlessui/react"; +import { Placement } from "@popperjs/core"; +// ui +import { Button } from "@plane/ui"; +// icons +import { ChevronUp } from "lucide-react"; + +type Props = { + children: React.ReactNode; + title?: string; + placement?: Placement; +}; + +export const FiltersDropdown: React.FC = (props) => { + const { children, title = "Dropdown", placement } = props; + + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "auto", + }); + + return ( + + {({ open }) => { + if (open) { + } + return ( + <> + + + + + +
+
{children}
+
+
+
+ + ); + }} +
+ ); +}; diff --git a/space/components/issues/filters/helpers/filter-header.tsx b/space/components/issues/filters/helpers/filter-header.tsx new file mode 100644 index 000000000..4513b0795 --- /dev/null +++ b/space/components/issues/filters/helpers/filter-header.tsx @@ -0,0 +1,22 @@ +import React from "react"; +// lucide icons +import { ChevronDown, ChevronUp } from "lucide-react"; + +interface IFilterHeader { + title: string; + isPreviewEnabled: boolean; + handleIsPreviewEnabled: () => void; +} + +export const FilterHeader = ({ title, isPreviewEnabled, handleIsPreviewEnabled }: IFilterHeader) => ( +
+
{title}
+ +
+); diff --git a/space/components/issues/filters/helpers/filter-option.tsx b/space/components/issues/filters/helpers/filter-option.tsx new file mode 100644 index 000000000..4b6f1b041 --- /dev/null +++ b/space/components/issues/filters/helpers/filter-option.tsx @@ -0,0 +1,35 @@ +import React from "react"; +// lucide icons +import { Check } from "lucide-react"; + +type Props = { + icon?: React.ReactNode; + isChecked: boolean; + title: React.ReactNode; + onClick?: () => void; + multiple?: boolean; +}; + +export const FilterOption: React.FC = (props) => { + const { icon, isChecked, multiple = true, onClick, title } = props; + + return ( + + ); +}; diff --git a/space/components/issues/filters/helpers/index.ts b/space/components/issues/filters/helpers/index.ts new file mode 100644 index 000000000..ef38d9884 --- /dev/null +++ b/space/components/issues/filters/helpers/index.ts @@ -0,0 +1,3 @@ +export * from "./dropdown"; +export * from "./filter-header"; +export * from "./filter-option"; diff --git a/space/components/issues/filters/index.ts b/space/components/issues/filters/index.ts new file mode 100644 index 000000000..56a01386d --- /dev/null +++ b/space/components/issues/filters/index.ts @@ -0,0 +1,11 @@ +// filters +export * from "./root"; +export * from "./selection"; + +// properties +export * from "./state"; +export * from "./priority"; +export * from "./labels"; + +// helpers +export * from "./helpers"; diff --git a/space/components/issues/filters/labels.tsx b/space/components/issues/filters/labels.tsx new file mode 100644 index 000000000..4b8aa3b4f --- /dev/null +++ b/space/components/issues/filters/labels.tsx @@ -0,0 +1,83 @@ +import React, { useState } from "react"; + +// components +import { FilterHeader, FilterOption } from "./helpers"; +// ui +import { Loader } from "@plane/ui"; +// types +import { IIssueLabel } from "types/issue"; + +const LabelIcons = ({ color }: { color: string }) => ( + +); + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + labels: IIssueLabel[] | undefined; + searchQuery: string; +}; + +export const FilterLabels: React.FC = (props) => { + const { appliedFilters, handleUpdate, labels, searchQuery } = props; + + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = labels?.filter((label) => label.name.toLowerCase().includes(searchQuery.toLowerCase())); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((label) => ( + handleUpdate(label?.id)} + icon={} + title={label.name} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}; diff --git a/space/components/issues/filters/priority.tsx b/space/components/issues/filters/priority.tsx new file mode 100644 index 000000000..94a7f6a8c --- /dev/null +++ b/space/components/issues/filters/priority.tsx @@ -0,0 +1,51 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// ui +import { PriorityIcon } from "@plane/ui"; +// components +import { FilterHeader, FilterOption } from "./helpers"; +// constants +import { issuePriorityFilters } from "constants/data"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterPriority: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = issuePriorityFilters.filter((p) => p.key.includes(searchQuery.toLowerCase())); + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((priority) => ( + handleUpdate(priority.key)} + icon={} + title={priority.title} + /> + )) + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/space/components/issues/filters/root.tsx b/space/components/issues/filters/root.tsx new file mode 100644 index 000000000..eb9946a24 --- /dev/null +++ b/space/components/issues/filters/root.tsx @@ -0,0 +1,77 @@ +import { FC, useCallback } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// components +import { FiltersDropdown } from "./helpers/dropdown"; +import { FilterSelection } from "./selection"; +// types +import { IIssueFilterOptions } from "store/issues/types"; +// helpers +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "store/issues/helpers"; +// store +import { RootStore } from "store/root"; +import { useMobxStore } from "lib/mobx/store-provider"; + +export const IssueFiltersDropdown: FC = observer(() => { + const router = useRouter(); + const { workspace_slug: workspaceSlug, project_slug: projectId } = router.query as { + workspace_slug: string; + project_slug: string; + }; + + const { + project: { activeBoard }, + issue: { states, labels }, + issuesFilter: { issueFilters, updateFilters }, + }: RootStore = useMobxStore(); + + const updateRouteParams = useCallback( + (key: keyof IIssueFilterOptions, value: string[]) => { + const state = key === "state" ? value : issueFilters?.filters?.state ?? []; + const priority = key === "priority" ? value : issueFilters?.filters?.priority ?? []; + const labels = key === "labels" ? value : issueFilters?.filters?.labels ?? []; + + let params: any = { board: activeBoard || "list" }; + if (priority.length > 0) params = { ...params, priorities: priority.join(",") }; + if (state.length > 0) params = { ...params, states: state.join(",") }; + if (labels.length > 0) params = { ...params, labels: labels.join(",") }; + + router.push({ pathname: `/${workspaceSlug}/${projectId}`, query: { ...params } }, undefined, { shallow: true }); + }, + [workspaceSlug, projectId, activeBoard, issueFilters, router] + ); + + const handleFilters = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(projectId, { [key]: newValues }); + updateRouteParams(key, newValues); + }, + [projectId, issueFilters, updateFilters, updateRouteParams] + ); + + return ( +
+ + + +
+ ); +}); diff --git a/space/components/issues/filters/selection.tsx b/space/components/issues/filters/selection.tsx new file mode 100644 index 000000000..e479a7d59 --- /dev/null +++ b/space/components/issues/filters/selection.tsx @@ -0,0 +1,86 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Search, X } from "lucide-react"; +// components +import { FilterLabels, FilterPriority, FilterState } from "./"; +// types + +// filter helpers +import { ILayoutDisplayFiltersOptions } from "store/issues/helpers"; +import { IIssueFilterOptions } from "store/issues/types"; +import { IIssueState, IIssueLabel } from "types/issue"; + +type Props = { + filters: IIssueFilterOptions; + handleFilters: (key: keyof IIssueFilterOptions, value: string | string[]) => void; + layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined; + labels?: IIssueLabel[] | undefined; + states?: IIssueState[] | undefined; +}; + +export const FilterSelection: React.FC = observer((props) => { + const { filters, handleFilters, layoutDisplayFiltersOptions, labels, states } = props; + + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + const isFilterEnabled = (filter: keyof IIssueFilterOptions) => layoutDisplayFiltersOptions?.filters.includes(filter); + + return ( +
+
+
+ + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+ {/* priority */} + {isFilterEnabled("priority") && ( +
+ handleFilters("priority", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* state */} + {isFilterEnabled("state") && ( +
+ handleFilters("state", val)} + searchQuery={filtersSearchQuery} + states={states} + /> +
+ )} + + {/* labels */} + {isFilterEnabled("labels") && ( +
+ handleFilters("labels", val)} + labels={labels} + searchQuery={filtersSearchQuery} + /> +
+ )} +
+
+ ); +}); diff --git a/space/components/issues/filters/state.tsx b/space/components/issues/filters/state.tsx new file mode 100644 index 000000000..1175a5ed6 --- /dev/null +++ b/space/components/issues/filters/state.tsx @@ -0,0 +1,78 @@ +import React, { useState } from "react"; +// components +import { FilterHeader, FilterOption } from "./helpers"; +// ui +import { Loader, StateGroupIcon } from "@plane/ui"; +// types +import { IIssueState } from "types/issue"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; + states: IIssueState[] | undefined; +}; + +export const FilterState: React.FC = (props) => { + const { appliedFilters, handleUpdate, searchQuery, states } = props; + + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = states?.filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase())); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((state) => ( + handleUpdate(state.id)} + icon={} + title={state.name} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}; diff --git a/space/components/issues/navbar/index.tsx b/space/components/issues/navbar/index.tsx index 220991bd9..d6491bc26 100644 --- a/space/components/issues/navbar/index.tsx +++ b/space/components/issues/navbar/index.tsx @@ -1,7 +1,6 @@ import { useEffect } from "react"; import Link from "next/link"; -import Image from "next/image"; import { useRouter } from "next/router"; // mobx @@ -10,12 +9,15 @@ import { observer } from "mobx-react-lite"; // import { NavbarSearch } from "./search"; import { NavbarIssueBoardView } from "./issue-board-view"; import { NavbarTheme } from "./theme"; +import { IssueFiltersDropdown } from "components/issues/filters"; // ui -import { PrimaryButton } from "components/ui"; +import { Avatar, Button } from "@plane/ui"; +import { Briefcase } from "lucide-react"; // lib import { useMobxStore } from "lib/mobx/store-provider"; // store import { RootStore } from "store/root"; +import { TIssueBoardKeys } from "types/issue"; const renderEmoji = (emoji: string | { name: string; color: string }) => { if (!emoji) return; @@ -30,10 +32,21 @@ const renderEmoji = (emoji: string | { name: string; color: string }) => { }; const IssueNavbar = observer(() => { - const { project: projectStore, user: userStore }: RootStore = useMobxStore(); + const { + project: projectStore, + user: userStore, + issuesFilter: { updateFilters }, + }: RootStore = useMobxStore(); // router const router = useRouter(); - const { workspace_slug, project_slug, board } = router.query; + const { workspace_slug, project_slug, board, states, priorities, labels } = router.query as { + workspace_slug: string; + project_slug: string; + board: string; + states: string; + priorities: string; + labels: string; + }; const user = userStore?.currentUser; @@ -46,7 +59,7 @@ const IssueNavbar = observer(() => { useEffect(() => { if (workspace_slug && project_slug && projectStore?.deploySettings) { const viewsAcceptable: string[] = []; - let currentBoard: string | null = null; + let currentBoard: TIssueBoardKeys | null = null; if (projectStore?.deploySettings?.views?.list) viewsAcceptable.push("list"); if (projectStore?.deploySettings?.views?.kanban) viewsAcceptable.push("kanban"); @@ -56,41 +69,65 @@ const IssueNavbar = observer(() => { if (board) { if (viewsAcceptable.includes(board.toString())) { - currentBoard = board.toString(); + currentBoard = board.toString() as TIssueBoardKeys; } else { if (viewsAcceptable && viewsAcceptable.length > 0) { - currentBoard = viewsAcceptable[0]; + currentBoard = viewsAcceptable[0] as TIssueBoardKeys; } } } else { if (viewsAcceptable && viewsAcceptable.length > 0) { - currentBoard = viewsAcceptable[0]; + currentBoard = viewsAcceptable[0] as TIssueBoardKeys; } } if (currentBoard) { if (projectStore?.activeBoard === null || projectStore?.activeBoard !== currentBoard) { + let params: any = { board: currentBoard }; + if (priorities && priorities.length > 0) params = { ...params, priorities: priorities }; + if (states && states.length > 0) params = { ...params, states: states }; + if (labels && labels.length > 0) params = { ...params, labels: labels }; + + let storeParams: any = {}; + if (priorities && priorities.length > 0) storeParams = { ...storeParams, priority: priorities.split(",") }; + if (states && states.length > 0) storeParams = { ...storeParams, state: states.split(",") }; + if (labels && labels.length > 0) storeParams = { ...storeParams, labels: labels.split(",") }; + + if (storeParams) updateFilters(project_slug, storeParams); + projectStore.setActiveBoard(currentBoard); router.push({ pathname: `/${workspace_slug}/${project_slug}`, - query: { - board: currentBoard, - }, + query: { ...params }, }); } } } - }, [board, workspace_slug, project_slug, router, projectStore, projectStore?.deploySettings]); + }, [board, workspace_slug, project_slug, router, projectStore, projectStore?.deploySettings, updateFilters]); return (
{/* project detail */}
- {projectStore?.project && projectStore?.project?.emoji ? ( - renderEmoji(projectStore?.project?.emoji) + {projectStore.project ? ( + projectStore.project?.emoji ? ( + + {renderEmoji(projectStore.project.emoji)} + + ) : projectStore.project?.icon_prop ? ( +
+ {renderEmoji(projectStore.project.icon_prop)} +
+ ) : ( + + {projectStore.project?.name.charAt(0)} + + ) ) : ( - plane logo + + + )}
@@ -106,6 +143,11 @@ const IssueNavbar = observer(() => {
+ {/* issue filters */} +
+ +
+ {/* theming */}
@@ -113,26 +155,13 @@ const IssueNavbar = observer(() => { {user ? (
- {user.avatar && user.avatar !== "" ? ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {user.display_name -
- ) : ( -
- {(user.display_name ?? "A")[0]} -
- )} +
{user.display_name}
) : (
- - - Sign in - - +
)} diff --git a/space/components/issues/navbar/issue-board-view.tsx b/space/components/issues/navbar/issue-board-view.tsx index 16b09229a..906d3543d 100644 --- a/space/components/issues/navbar/issue-board-view.tsx +++ b/space/components/issues/navbar/issue-board-view.tsx @@ -5,6 +5,7 @@ import { issueViews } from "constants/data"; // mobx import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; +import { TIssueBoardKeys } from "types/issue"; export const NavbarIssueBoardView = observer(() => { const { @@ -15,7 +16,7 @@ export const NavbarIssueBoardView = observer(() => { const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; const handleCurrentBoardView = (boardView: string) => { - setActiveBoard(boardView); + setActiveBoard(boardView as TIssueBoardKeys); router.push(`/${workspace_slug}/${project_slug}?board=${boardView}`); }; diff --git a/space/components/issues/navbar/issue-filter.tsx b/space/components/issues/navbar/issue-filter.tsx deleted file mode 100644 index 83d5159d6..000000000 --- a/space/components/issues/navbar/issue-filter.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import { ChevronDown } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -import { RootStore } from "store/root"; -// components -import { Dropdown } from "components/ui/dropdown"; -// constants -import { issueGroupFilter } from "constants/data"; - -const PRIORITIES = ["urgent", "high", "medium", "low"]; - -export const NavbarIssueFilter = observer(() => { - const store: RootStore = useMobxStore(); - - const router = useRouter(); - const pathName = router.asPath; - - const handleOnSelect = (key: "states" | "labels" | "priorities", value: string) => { - // if (key === "states") { - // store.issue.userSelectedStates = store.issue.userSelectedStates.includes(value) - // ? store.issue.userSelectedStates.filter((s) => s !== value) - // : [...store.issue.userSelectedStates, value]; - // } else if (key === "labels") { - // store.issue.userSelectedLabels = store.issue.userSelectedLabels.includes(value) - // ? store.issue.userSelectedLabels.filter((l) => l !== value) - // : [...store.issue.userSelectedLabels, value]; - // } else if (key === "priorities") { - // store.issue.userSelectedPriorities = store.issue.userSelectedPriorities.includes(value) - // ? store.issue.userSelectedPriorities.filter((p) => p !== value) - // : [...store.issue.userSelectedPriorities, value]; - // } - // const paramsCommaSeparated = `${`board=${store.issue.currentIssueBoardView || "list"}`}${ - // store.issue.userSelectedPriorities.length > 0 ? `&priorities=${store.issue.userSelectedPriorities.join(",")}` : "" - // }${store.issue.userSelectedStates.length > 0 ? `&states=${store.issue.userSelectedStates.join(",")}` : ""}${ - // store.issue.userSelectedLabels.length > 0 ? `&labels=${store.issue.userSelectedLabels.join(",")}` : "" - // }`; - // router.replace(`${pathName}?${paramsCommaSeparated}`); - }; - - return ( - - Filters -
)} diff --git a/space/components/issues/peek-overview/issue-emoji-reactions.tsx b/space/components/issues/peek-overview/issue-emoji-reactions.tsx index b0c5b0361..dfd45f62b 100644 --- a/space/components/issues/peek-overview/issue-emoji-reactions.tsx +++ b/space/components/issues/peek-overview/issue-emoji-reactions.tsx @@ -6,7 +6,8 @@ import { useMobxStore } from "lib/mobx/store-provider"; // helpers import { groupReactions, renderEmoji } from "helpers/emoji.helper"; // components -import { ReactionSelector, Tooltip } from "components/ui"; +import { ReactionSelector } from "components/ui"; +import { Tooltip } from "@plane/ui"; export const IssueEmojiReactions: React.FC = observer(() => { // router diff --git a/space/components/issues/peek-overview/issue-vote-reactions.tsx b/space/components/issues/peek-overview/issue-vote-reactions.tsx index ac20565ea..8d619681a 100644 --- a/space/components/issues/peek-overview/issue-vote-reactions.tsx +++ b/space/components/issues/peek-overview/issue-vote-reactions.tsx @@ -6,7 +6,8 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // lib import { useMobxStore } from "lib/mobx/store-provider"; -import { Tooltip } from "components/ui"; +// ui +import { Tooltip } from "@plane/ui"; export const IssueVotes: React.FC = observer(() => { const [isSubmitting, setIsSubmitting] = useState(false); diff --git a/space/components/issues/peek-overview/side-peek-view.tsx b/space/components/issues/peek-overview/side-peek-view.tsx index bacf83420..f0fc3d83e 100644 --- a/space/components/issues/peek-overview/side-peek-view.tsx +++ b/space/components/issues/peek-overview/side-peek-view.tsx @@ -7,7 +7,7 @@ import { PeekOverviewIssueProperties, } from "components/issues/peek-overview"; -import { Loader } from "components/ui/loader"; +import { Loader } from "@plane/ui"; import { IIssue } from "types/issue"; type Props = { diff --git a/space/components/ui/index.ts b/space/components/ui/index.ts index e44096909..1e523d5dd 100644 --- a/space/components/ui/index.ts +++ b/space/components/ui/index.ts @@ -1,8 +1,3 @@ export * from "./dropdown"; -export * from "./input"; -export * from "./loader"; -export * from "./primary-button"; -export * from "./secondary-button"; export * from "./icon"; export * from "./reaction-selector"; -export * from "./tooltip"; diff --git a/space/components/ui/input.tsx b/space/components/ui/input.tsx deleted file mode 100644 index b6be82ae5..000000000 --- a/space/components/ui/input.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { forwardRef, Ref } from "react"; - -// types -interface Props extends React.InputHTMLAttributes { - mode?: "primary" | "transparent" | "trueTransparent"; - error?: boolean; - inputSize?: "rg" | "lg"; - fullWidth?: boolean; -} - -export const Input = forwardRef((props: Props, ref: Ref) => { - const { mode = "primary", error, className = "", type, fullWidth = true, id, inputSize = "rg", ...rest } = props; - - return ( - - ); -}); - -Input.displayName = "Input"; - -export default Input; diff --git a/space/components/ui/loader.tsx b/space/components/ui/loader.tsx deleted file mode 100644 index b9d13883a..000000000 --- a/space/components/ui/loader.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from "react"; - -type Props = { - children: React.ReactNode; - className?: string; -}; - -const Loader = ({ children, className = "" }: Props) => ( -
- {children} -
-); - -type ItemProps = { - height?: string; - width?: string; -}; - -const Item: React.FC = ({ height = "auto", width = "auto" }) => ( -
-); - -Loader.Item = Item; - -export { Loader }; diff --git a/space/components/ui/primary-button.tsx b/space/components/ui/primary-button.tsx deleted file mode 100644 index b3e1b82ee..000000000 --- a/space/components/ui/primary-button.tsx +++ /dev/null @@ -1,35 +0,0 @@ -interface ButtonProps extends React.ButtonHTMLAttributes { - size?: "sm" | "md" | "lg"; - outline?: boolean; - loading?: boolean; -} - -export const PrimaryButton: React.FC = ({ - children, - className = "", - onClick, - type = "button", - disabled = false, - loading = false, - size = "sm", - outline = false, -}) => ( - -); diff --git a/space/components/ui/secondary-button.tsx b/space/components/ui/secondary-button.tsx deleted file mode 100644 index 2a9b3d528..000000000 --- a/space/components/ui/secondary-button.tsx +++ /dev/null @@ -1,35 +0,0 @@ -interface ButtonProps extends React.ButtonHTMLAttributes { - size?: "sm" | "md" | "lg"; - outline?: boolean; - loading?: boolean; -} - -export const SecondaryButton: React.FC = ({ - children, - className = "", - onClick, - type = "button", - disabled = false, - loading = false, - size = "sm", - outline = false, -}) => ( - -); diff --git a/space/components/ui/tooltip.tsx b/space/components/ui/tooltip.tsx deleted file mode 100644 index 64876ffc0..000000000 --- a/space/components/ui/tooltip.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from "react"; -// next-themes -import { useTheme } from "next-themes"; -// tooltip2 -import { Tooltip2 } from "@blueprintjs/popover2"; - -type Props = { - tooltipHeading?: string; - tooltipContent: string | React.ReactNode; - position?: - | "top" - | "right" - | "bottom" - | "left" - | "auto" - | "auto-end" - | "auto-start" - | "bottom-left" - | "bottom-right" - | "left-bottom" - | "left-top" - | "right-bottom" - | "right-top" - | "top-left" - | "top-right"; - children: JSX.Element; - disabled?: boolean; - className?: string; - openDelay?: number; - closeDelay?: number; -}; - -export const Tooltip: React.FC = ({ - tooltipHeading, - tooltipContent, - position = "top", - children, - disabled = false, - className = "", - openDelay = 200, - closeDelay, -}) => { - const { theme } = useTheme(); - - return ( - - {tooltipHeading && ( -
- {tooltipHeading} -
- )} - {tooltipContent} -
- } - position={position} - renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) => - React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props }) - } - /> - ); -}; diff --git a/space/components/views/project-details.tsx b/space/components/views/project-details.tsx index cd2658279..520def908 100644 --- a/space/components/views/project-details.tsx +++ b/space/components/views/project-details.tsx @@ -9,6 +9,7 @@ import { IssueCalendarView } from "components/issues/board-views/calendar"; import { IssueSpreadsheetView } from "components/issues/board-views/spreadsheet"; import { IssueGanttView } from "components/issues/board-views/gantt"; import { IssuePeekOverview } from "components/issues/peek-overview"; +import { IssueAppliedFilters } from "components/issues/filters/applied-filters/root"; // mobx store import { RootStore } from "store/root"; import { useMobxStore } from "lib/mobx/store-provider"; @@ -71,7 +72,10 @@ export const ProjectDetailsView = observer(() => {
) : ( projectStore?.activeBoard && ( - <> +
+ {/* applied filters */} + + {projectStore?.activeBoard === "list" && (
@@ -85,7 +89,7 @@ export const ProjectDetailsView = observer(() => { {projectStore?.activeBoard === "calendar" && } {projectStore?.activeBoard === "spreadsheet" && } {projectStore?.activeBoard === "gantt" && } - +
) )} diff --git a/space/layouts/project-layout.tsx b/space/layouts/project-layout.tsx index 1a0b7899e..c8bdfd9a1 100644 --- a/space/layouts/project-layout.tsx +++ b/space/layouts/project-layout.tsx @@ -1,4 +1,3 @@ -import Link from "next/link"; import Image from "next/image"; // mobx diff --git a/space/store/issue.ts b/space/store/issue.ts index d47336984..02dd3cdd0 100644 --- a/space/store/issue.ts +++ b/space/store/issue.ts @@ -1,4 +1,4 @@ -import { observable, action, computed, makeObservable, runInAction, reaction } from "mobx"; +import { observable, action, computed, makeObservable, runInAction } from "mobx"; // services import IssueService from "services/issue.service"; // store diff --git a/space/store/issues/base-issue-filter.store.ts b/space/store/issues/base-issue-filter.store.ts new file mode 100644 index 000000000..2cd2e3bc9 --- /dev/null +++ b/space/store/issues/base-issue-filter.store.ts @@ -0,0 +1,29 @@ +// types +import { RootStore } from "store/root"; + +export interface IIssueFilterBaseStore { + // helper methods + computedFilter(filters: any, filteredParams: any): any; +} + +export class IssueFilterBaseStore implements IIssueFilterBaseStore { + // root store + rootStore; + + constructor(_rootStore: RootStore) { + // root store + this.rootStore = _rootStore; + } + + // helper methods + computedFilter = (filters: any, filteredParams: any) => { + const computedFilters: any = {}; + Object.keys(filters).map((key) => { + if (filters[key] != undefined && filteredParams.includes(key)) + computedFilters[key] = + typeof filters[key] === "string" || typeof filters[key] === "boolean" ? filters[key] : filters[key].join(","); + }); + + return computedFilters; + }; +} diff --git a/space/store/issues/helpers.ts b/space/store/issues/helpers.ts new file mode 100644 index 000000000..a862ca6e0 --- /dev/null +++ b/space/store/issues/helpers.ts @@ -0,0 +1,52 @@ +import { TIssueBoardKeys } from "types/issue"; +import { IIssueFilterOptions, TIssueParams } from "./types"; + +export const isNil = (value: any) => { + if (value === undefined || value === null) return true; + + return false; +}; + +export interface ILayoutDisplayFiltersOptions { + filters: (keyof IIssueFilterOptions)[]; + display_properties: boolean | null; + display_filters: null; + extra_options: null; +} + +export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { + [pageType: string]: { [layoutType: string]: ILayoutDisplayFiltersOptions }; +} = { + issues: { + list: { + filters: ["priority", "state", "labels"], + display_properties: null, + display_filters: null, + extra_options: null, + }, + kanban: { + filters: ["priority", "state", "labels"], + display_properties: null, + display_filters: null, + extra_options: null, + }, + }, +}; + +export const handleIssueQueryParamsByLayout = ( + layout: TIssueBoardKeys | undefined, + viewType: "issues" +): TIssueParams[] | null => { + const queryParams: TIssueParams[] = []; + + if (!layout) return null; + + const layoutOptions = ISSUE_DISPLAY_FILTERS_BY_LAYOUT[viewType][layout]; + + // add filters query params + layoutOptions.filters.forEach((option) => { + queryParams.push(option); + }); + + return queryParams; +}; diff --git a/space/store/issues/issue-filters.store.ts b/space/store/issues/issue-filters.store.ts new file mode 100644 index 000000000..f2408e290 --- /dev/null +++ b/space/store/issues/issue-filters.store.ts @@ -0,0 +1,106 @@ +import { action, makeObservable, observable, runInAction, computed } from "mobx"; +// types +import { RootStore } from "store/root"; +import { IIssueFilterOptions, TIssueParams } from "./types"; +import { handleIssueQueryParamsByLayout } from "./helpers"; +import { IssueFilterBaseStore } from "./base-issue-filter.store"; + +interface IFiltersOptions { + filters: IIssueFilterOptions; +} + +export interface IIssuesFilterStore { + // observables + projectIssueFilters: { [projectId: string]: IFiltersOptions } | undefined; + // computed + issueFilters: IFiltersOptions | undefined; + appliedFilters: TIssueParams[] | undefined; + // helpers + issueDisplayFilters: (projectId: string) => IFiltersOptions | undefined; + // actions + updateFilters: (projectId: string, filters: IIssueFilterOptions) => Promise; +} + +export class IssuesFilterStore extends IssueFilterBaseStore implements IIssuesFilterStore { + // observables + projectIssueFilters: { [projectId: string]: IFiltersOptions } | undefined = undefined; + // root store + rootStore; + + constructor(_rootStore: RootStore) { + super(_rootStore); + + makeObservable(this, { + // observables + projectIssueFilters: observable.ref, + // computed + issueFilters: computed, + appliedFilters: computed, + // actions + updateFilters: action, + }); + // root store + this.rootStore = _rootStore; + } + + // helpers + issueDisplayFilters = (projectId: string) => { + if (!projectId) return undefined; + return this.projectIssueFilters?.[projectId] || undefined; + }; + + // actions + + updateFilters = async (projectId: string, filters: IIssueFilterOptions) => { + try { + let _projectIssueFilters = { ...this.projectIssueFilters }; + if (!_projectIssueFilters) _projectIssueFilters = {}; + if (!_projectIssueFilters[projectId]) _projectIssueFilters[projectId] = { filters: {} }; + + const _filters = { + filters: { ..._projectIssueFilters[projectId].filters }, + }; + + _filters.filters = { ..._filters.filters, ...filters }; + + _projectIssueFilters[projectId] = { + filters: _filters.filters, + }; + + runInAction(() => { + this.projectIssueFilters = _projectIssueFilters; + }); + + return _filters; + } catch (error) { + throw error; + } + }; + + get issueFilters() { + const projectId = this.rootStore.project.project?.id; + if (!projectId) return undefined; + + const issueFilters = this.issueDisplayFilters(projectId); + if (!issueFilters) return undefined; + + return issueFilters; + } + + get appliedFilters() { + const userFilters = this.issueFilters; + const layout = this.rootStore.project?.activeBoard; + if (!userFilters || !layout) return undefined; + + let filteredRouteParams: any = { + priority: userFilters?.filters?.priority || undefined, + state: userFilters?.filters?.state || undefined, + labels: userFilters?.filters?.labels || undefined, + }; + + const filteredParams = handleIssueQueryParamsByLayout(layout, "issues"); + if (filteredParams) filteredRouteParams = this.computedFilter(filteredRouteParams, filteredParams); + + return filteredRouteParams; + } +} diff --git a/space/store/issues/types.ts b/space/store/issues/types.ts new file mode 100644 index 000000000..d1de0a5ea --- /dev/null +++ b/space/store/issues/types.ts @@ -0,0 +1,36 @@ +import { IIssue } from "types/issue"; + +export type TIssueGroupByOptions = "state" | "priority" | "labels" | null; + +export type TIssueParams = "priority" | "state" | "labels"; + +export interface IIssueFilterOptions { + state?: string[] | null; + labels?: string[] | null; + priority?: string[] | null; +} + +// issues +export interface IGroupedIssues { + [group_id: string]: string[]; +} + +export interface ISubGroupedIssues { + [sub_grouped_id: string]: { + [group_id: string]: string[]; + }; +} + +export type TUnGroupedIssues = string[]; + +export interface IIssueResponse { + [issue_id: string]: IIssue; +} + +export type TLoader = "init-loader" | "mutation" | undefined; + +export interface ViewFlags { + enableQuickAdd: boolean; + enableIssueCreation: boolean; + enableInlineEditing: boolean; +} diff --git a/space/store/project.ts b/space/store/project.ts index ddd589f9a..76b4d06cb 100644 --- a/space/store/project.ts +++ b/space/store/project.ts @@ -2,6 +2,7 @@ import { observable, action, makeObservable, runInAction } from "mobx"; // service import ProjectService from "services/project.service"; +import { TIssueBoardKeys } from "types/issue"; // types import { IWorkspace, IProject, IProjectSettings } from "types/project"; @@ -12,9 +13,9 @@ export interface IProjectStore { project: IProject | null; deploySettings: IProjectSettings | null; viewOptions: any; - activeBoard: string | null; + activeBoard: TIssueBoardKeys | null; fetchProjectSettings: (workspace_slug: string, project_slug: string) => Promise; - setActiveBoard: (value: string) => void; + setActiveBoard: (value: TIssueBoardKeys) => void; } class ProjectStore implements IProjectStore { @@ -25,7 +26,7 @@ class ProjectStore implements IProjectStore { project: IProject | null = null; deploySettings: IProjectSettings | null = null; viewOptions: any = null; - activeBoard: string | null = null; + activeBoard: TIssueBoardKeys | null = null; // root store rootStore; // service @@ -80,7 +81,7 @@ class ProjectStore implements IProjectStore { } }; - setActiveBoard = (boardValue: string) => { + setActiveBoard = (boardValue: TIssueBoardKeys) => { this.activeBoard = boardValue; }; } diff --git a/space/store/root.ts b/space/store/root.ts index 22b951d20..5a9e0bca1 100644 --- a/space/store/root.ts +++ b/space/store/root.ts @@ -6,6 +6,7 @@ import IssueStore, { IIssueStore } from "./issue"; import ProjectStore, { IProjectStore } from "./project"; import IssueDetailStore, { IIssueDetailStore } from "./issue_details"; import { IMentionsStore, MentionsStore } from "./mentions.store"; +import { IIssuesFilterStore, IssuesFilterStore } from "./issues/issue-filters.store"; enableStaticRendering(typeof window === "undefined"); @@ -15,6 +16,7 @@ export class RootStore { issueDetails: IIssueDetailStore; project: IProjectStore; mentionsStore: IMentionsStore; + issuesFilter: IIssuesFilterStore; constructor() { this.user = new UserStore(this); @@ -22,5 +24,6 @@ export class RootStore { this.project = new ProjectStore(this); this.issueDetails = new IssueDetailStore(this); this.mentionsStore = new MentionsStore(this); + this.issuesFilter = new IssuesFilterStore(this); } } diff --git a/web/components/account/sign-in-forms/email-form.tsx b/web/components/account/sign-in-forms/email-form.tsx index f704d4134..d9dc1c396 100644 --- a/web/components/account/sign-in-forms/email-form.tsx +++ b/web/components/account/sign-in-forms/email-form.tsx @@ -84,7 +84,7 @@ export const EmailForm: React.FC = (props) => { return ( <>

- Get on your flight deck! + Get on your flight deck

Sign in with the email you used to sign up for Plane diff --git a/web/components/account/sign-in-forms/password.tsx b/web/components/account/sign-in-forms/password.tsx index 547f59e9d..44c91a51f 100644 --- a/web/components/account/sign-in-forms/password.tsx +++ b/web/components/account/sign-in-forms/password.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; @@ -36,6 +36,8 @@ const authService = new AuthService(); export const PasswordForm: React.FC = (props) => { const { email, updateEmail, handleStepChange, handleSignInRedirection } = props; + // states + const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false); // toast alert const { setToastAlert } = useToast(); // form info @@ -113,6 +115,8 @@ export const PasswordForm: React.FC = (props) => { return; } + setIsSendingResetPasswordLink(true); + authService .sendResetPasswordLink({ email: emailFormValue }) .then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK)) @@ -122,7 +126,8 @@ export const PasswordForm: React.FC = (props) => { title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }) - ); + ) + .finally(() => setIsSendingResetPasswordLink(false)); }; return ( @@ -189,9 +194,12 @@ export const PasswordForm: React.FC = (props) => {

diff --git a/web/components/account/sign-in-forms/root.tsx b/web/components/account/sign-in-forms/root.tsx index 9797c8573..90e60e2b1 100644 --- a/web/components/account/sign-in-forms/root.tsx +++ b/web/components/account/sign-in-forms/root.tsx @@ -1,4 +1,6 @@ import React, { useState } from "react"; +// hooks +import useSignInRedirection from "hooks/use-sign-in-redirection"; // components import { EmailForm, @@ -19,33 +21,27 @@ export enum ESignInSteps { CREATE_PASSWORD = "CREATE_PASSWORD", } -type Props = { - handleSignInRedirection: () => Promise; -}; - const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD]; -export const SignInRoot: React.FC = (props) => { - const { handleSignInRedirection } = props; +export const SignInRoot = () => { // states const [signInStep, setSignInStep] = useState(ESignInSteps.EMAIL); const [email, setEmail] = useState(""); + // sign in redirection hook + const { handleRedirection } = useSignInRedirection(); return ( <>
{signInStep === ESignInSteps.EMAIL && ( - setSignInStep(step)} - updateEmail={(newEmail) => setEmail(newEmail)} - /> + setSignInStep(step)} updateEmail={(newEmail) => setEmail(newEmail)} /> )} {signInStep === ESignInSteps.PASSWORD && ( setEmail(newEmail)} - handleStepChange={(step: ESignInSteps) => setSignInStep(step)} - handleSignInRedirection={handleSignInRedirection} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} /> )} {signInStep === ESignInSteps.SET_PASSWORD_LINK && ( @@ -55,30 +51,30 @@ export const SignInRoot: React.FC = (props) => { setEmail(newEmail)} - handleStepChange={(step: ESignInSteps) => setSignInStep(step)} - handleSignInRedirection={handleSignInRedirection} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} /> )} {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( setSignInStep(step)} - handleSignInRedirection={handleSignInRedirection} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} /> )} {signInStep === ESignInSteps.CREATE_PASSWORD && ( setSignInStep(step)} - handleSignInRedirection={handleSignInRedirection} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} /> )}
{!OAUTH_HIDDEN_STEPS.includes(signInStep) && ( setEmail(newEmail)} - handleStepChange={(step: ESignInSteps) => setSignInStep(step)} - handleSignInRedirection={handleSignInRedirection} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} /> )} diff --git a/web/components/account/sign-in-forms/set-password-link.tsx b/web/components/account/sign-in-forms/set-password-link.tsx index f683a26fc..21cc8db17 100644 --- a/web/components/account/sign-in-forms/set-password-link.tsx +++ b/web/components/account/sign-in-forms/set-password-link.tsx @@ -30,7 +30,7 @@ export const SetPasswordLink: React.FC = (props) => { const { control, formState: { errors, isValid }, - watch, + handleSubmit, } = useForm({ defaultValues: { email, @@ -39,11 +39,13 @@ export const SetPasswordLink: React.FC = (props) => { reValidateMode: "onChange", }); - const handleSendNewLink = async () => { + const handleSendNewLink = async (formData: { email: string }) => { setIsSendingNewLink(true); + updateEmail(formData.email); + const payload: IEmailCheckData = { - email: watch("email"), + email: formData.email, type: "password", }; @@ -76,7 +78,7 @@ export const SetPasswordLink: React.FC = (props) => { password

-
+
= (props) => { name="email" type="email" value={value} - onChange={(e) => { - updateEmail(e.target.value); - onChange(e.target.value); - }} + onChange={onChange} ref={ref} hasError={Boolean(errors.email)} placeholder="orville.wright@firstflight.com" @@ -112,11 +111,10 @@ export const SetPasswordLink: React.FC = (props) => { />
@@ -74,7 +77,7 @@ export const AutoArchiveAutomation: React.FC = observer((props) => { }} input width="w-full" - disabled={userRole !== 20} + disabled={!isAdmin} > <> {PROJECT_AUTOMATION_MONTHS.map((month) => ( diff --git a/web/components/automation/auto-close-automation.tsx b/web/components/automation/auto-close-automation.tsx index 1f0ef1c31..30b1bdc0f 100644 --- a/web/components/automation/auto-close-automation.tsx +++ b/web/components/automation/auto-close-automation.tsx @@ -11,6 +11,7 @@ import { ArchiveX } from "lucide-react"; import { IProject } from "types"; // fetch keys import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; +import { EUserWorkspaceRoles } from "constants/workspace"; type Props = { handleChange: (formData: Partial) => Promise; @@ -53,6 +54,8 @@ export const AutoCloseAutomation: React.FC = observer((props) => { default_state: defaultState, }; + const isAdmin = userRole === EUserWorkspaceRoles.ADMIN; + return ( <> = observer((props) => { : handleChange({ close_in: 0, default_state: null }) } size="sm" - disabled={userRole !== 20} + disabled={!isAdmin} />
@@ -102,7 +105,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => { }} input width="w-full" - disabled={userRole !== 20} + disabled={!isAdmin} > <> {PROJECT_AUTOMATION_MONTHS.map((month) => ( diff --git a/web/components/core/image-picker-popover.tsx b/web/components/core/image-picker-popover.tsx index eea18ce43..765bb56b2 100644 --- a/web/components/core/image-picker-popover.tsx +++ b/web/components/core/image-picker-popover.tsx @@ -324,7 +324,7 @@ export const ImagePickerPopover: React.FC = observer((props) => { File formats supported- .jpeg, .jpg, .png, .webp, .svg

-
+
{!isCompleted && ( - { - setTrackElement("CYCLE_PAGE_SIDEBAR"); - setCycleDeleteModal(true) - } - }> + { + setTrackElement("CYCLE_PAGE_SIDEBAR"); + setCycleDeleteModal(true); + }} + > Delete cycle diff --git a/web/components/cycles/transfer-issues-modal.tsx b/web/components/cycles/transfer-issues-modal.tsx index bcbd8efef..55555e221 100644 --- a/web/components/cycles/transfer-issues-modal.tsx +++ b/web/components/cycles/transfer-issues-modal.tsx @@ -2,10 +2,12 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; import useSWR from "swr"; import { Dialog, Transition } from "@headlessui/react"; +import { observer } from "mobx-react-lite"; // services import { CycleService } from "services/cycle.service"; // hooks import useToast from "hooks/use-toast"; +import { useMobxStore } from "lib/mobx/store-provider"; //icons import { ContrastIcon, TransferIcon } from "@plane/ui"; import { AlertCircle, Search, X } from "lucide-react"; @@ -23,17 +25,19 @@ type Props = { const cycleService = new CycleService(); -export const TransferIssuesModal: React.FC = ({ isOpen, handleClose }) => { +export const TransferIssuesModal: React.FC = observer(({ isOpen, handleClose }) => { const [query, setQuery] = useState(""); + const { cycleIssues: cycleIssueStore } = useMobxStore(); + const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query; const { setToastAlert } = useToast(); const transferIssue = async (payload: any) => { - await cycleService - .transferIssues(workspaceSlug as string, projectId as string, cycleId as string, payload) + await cycleIssueStore + .transferIssuesFromCycle(workspaceSlug as string, projectId as string, cycleId as string, payload) .then(() => { setToastAlert({ type: "success", @@ -159,4 +163,4 @@ export const TransferIssuesModal: React.FC = ({ isOpen, handleClose }) => ); -}; +}); diff --git a/web/components/inbox/actions-header.tsx b/web/components/inbox/actions-header.tsx index be077df5c..83cdf1446 100644 --- a/web/components/inbox/actions-header.tsx +++ b/web/components/inbox/actions-header.tsx @@ -22,6 +22,7 @@ import { Button } from "@plane/ui"; import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Inbox, Trash2, XCircle } from "lucide-react"; // types import type { TInboxStatus } from "types"; +import { EUserWorkspaceRoles } from "constants/workspace"; export const InboxActionsHeader = observer(() => { const [date, setDate] = useState(new Date()); @@ -71,7 +72,7 @@ export const InboxActionsHeader = observer(() => { }, [issue]); const issueStatus = issue?.issue_inbox[0].status; - const isAllowed = userRole === 15 || userRole === 20; + const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER; const today = new Date(); const tomorrow = new Date(today); diff --git a/web/components/inbox/main-content.tsx b/web/components/inbox/main-content.tsx index 02ead34cb..193f59263 100644 --- a/web/components/inbox/main-content.tsx +++ b/web/components/inbox/main-content.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import Router, { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; @@ -8,14 +8,15 @@ import { AlertTriangle, CheckCircle2, Clock, Copy, ExternalLink, Inbox, XCircle // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // components -import { IssueDescriptionForm, IssueDetailsSidebar, IssueReaction } from "components/issues"; +import { IssueDescriptionForm, IssueDetailsSidebar, IssueReaction, IssueUpdateStatus } from "components/issues"; import { InboxIssueActivity } from "components/inbox"; // ui -import { Loader } from "@plane/ui"; +import { Loader, StateGroupIcon } from "@plane/ui"; // helpers import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; // types import { IInboxIssue, IIssue } from "types"; +import { EUserWorkspaceRoles } from "constants/workspace"; const defaultValues: Partial = { name: "", @@ -30,7 +31,15 @@ export const InboxMainContent: React.FC = observer(() => { const router = useRouter(); const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query; - const { inboxIssues: inboxIssuesStore, inboxIssueDetails: inboxIssueDetailsStore, user: userStore } = useMobxStore(); + // states + const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); + + const { + inboxIssues: inboxIssuesStore, + inboxIssueDetails: inboxIssueDetailsStore, + user: userStore, + projectState: { states }, + } = useMobxStore(); const user = userStore.currentUser; const userRole = userStore.currentProjectRole; @@ -54,6 +63,9 @@ export const InboxMainContent: React.FC = observer(() => { const issuesList = inboxId ? inboxIssuesStore.inboxIssues[inboxId.toString()] : undefined; const issueDetails = inboxIssueId ? inboxIssueDetailsStore.issueDetails[inboxIssueId.toString()] : undefined; + const currentIssueState = projectId + ? states[projectId.toString()]?.find((s) => s.id === issueDetails?.state) + : undefined; const submitChanges = useCallback( async (formData: Partial) => { @@ -144,6 +156,8 @@ export const InboxMainContent: React.FC = observer(() => {
); + const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER; + return ( <> {issueDetails ? ( @@ -214,15 +228,27 @@ export const InboxMainContent: React.FC = observer(() => { ) : null}
+
+ {currentIssueState && ( + + )} + +
setIsSubmitting(value)} + isSubmitting={isSubmitting} workspaceSlug={workspaceSlug as string} issue={{ name: issueDetails.name, description_html: issueDetails.description_html, }} handleFormSubmit={submitChanges} - isAllowed={userRole === 15 || userRole === 20 || user?.id === issueDetails.created_by} + isAllowed={isAllowed || user?.id === issueDetails.created_by} />
diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index 43600533b..ef26e22d8 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -26,14 +26,15 @@ export interface IssueDetailsProps { workspaceSlug: string; handleFormSubmit: (value: IssueDescriptionFormValues) => Promise; isAllowed: boolean; + isSubmitting: "submitting" | "submitted" | "saved"; + setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; } const fileService = new FileService(); export const IssueDescriptionForm: FC = (props) => { - const { issue, handleFormSubmit, workspaceSlug, isAllowed } = props; + const { issue, handleFormSubmit, workspaceSlug, isAllowed, isSubmitting, setIsSubmitting } = props; // states - const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); const [characterLimit, setCharacterLimit] = useState(false); const { setShowAlert } = useReloadConfirmations(); @@ -166,13 +167,6 @@ export const IssueDescriptionForm: FC = (props) => { /> )} /> -
- {isSubmitting === "submitting" ? "Saving..." : "Saved"} -
); diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index 0cf3c8bda..f8f0ba003 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -15,6 +15,7 @@ export * from "./sidebar"; export * from "./label"; export * from "./issue-reaction"; export * from "./confirm-issue-discard"; +export * from "./issue-update-status"; // draft issue export * from "./draft-issue-form"; diff --git a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx index b2a75e2d3..bf7ed2b42 100644 --- a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -25,6 +25,7 @@ import { IViewIssuesStore, } from "store/issues"; import { TUnGroupedIssues } from "store/issues/types"; +import { EUserWorkspaceRoles } from "constants/workspace"; interface IBaseGanttRoot { issueFiltersStore: @@ -69,7 +70,7 @@ export const BaseGanttRoot: React.FC = observer((props: IBaseGan ); }; - const isAllowed = currentProjectRole && currentProjectRole >= 15; + const isAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; return ( <> diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index e63b6e745..e5d279809 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -31,6 +31,7 @@ import { KanBan } from "./default"; import { KanBanSwimLanes } from "./swimlanes"; import { EProjectStore } from "store/command-palette.store"; import { IssuePeekOverview } from "components/issues"; +import { EUserWorkspaceRoles } from "constants/workspace"; export interface IBaseKanBanLayout { issueStore: @@ -93,7 +94,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas } = useMobxStore(); const { currentProjectRole } = userStore; - const isEditingAllowed = [15, 20].includes(currentProjectRole || 0); + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; const issues = issueStore?.getIssues || {}; const issueIds = issueStore?.getIssuesIds || []; @@ -223,7 +224,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas isDragStarted={isDragStarted} quickAddCallback={issueStore?.quickAddIssue} viewId={viewId} - disableIssueCreation={!enableIssueCreation} + disableIssueCreation={!enableIssueCreation || !isEditingAllowed} isReadOnly={!enableInlineEditing || !isEditingAllowed} currentStore={currentStore} addIssuesToView={addIssuesToView} diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 316d88144..4b00361b0 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -1,4 +1,6 @@ +import { memo } from "react"; import { Draggable } from "@hello-pangea/dnd"; +import isEqual from "lodash/isEqual"; // components import { KanBanProperties } from "./properties"; // ui @@ -21,7 +23,7 @@ interface IssueBlockProps { isReadOnly: boolean; } -export const KanbanIssueBlock: React.FC = (props) => { +export const KanBanIssueMemoBlock: React.FC = (props) => { const { sub_group_id, columnId, @@ -63,30 +65,36 @@ export const KanbanIssueBlock: React.FC = (props) => { {...provided.draggableProps} {...provided.dragHandleProps} ref={provided.innerRef} - onClick={handleIssuePeekOverview} > {issue.tempId !== undefined && (
)} -
- {quickActions( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !columnId && columnId === "null" ? null : columnId, - issue - )} -
{displayProperties && displayProperties?.key && ( -
- {issue.project_detail.identifier}-{issue.sequence_id} +
+
+ {issue.project_detail.identifier}-{issue.sequence_id} +
+
+ {quickActions( + !sub_group_id && sub_group_id === "null" ? null : sub_group_id, + !columnId && columnId === "null" ? null : columnId, + issue + )} +
)} -
{issue.name}
+
+ {issue.name} +
= (props) => { ); }; + +const validateMemo = (prevProps: IssueBlockProps, nextProps: IssueBlockProps) => { + if (prevProps.issue != nextProps.issue) return true; + return false; +}; + +export const KanbanIssueBlock = memo(KanBanIssueMemoBlock, validateMemo); diff --git a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index f58001402..8fc2c6e17 100644 --- a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; // components import { CustomMenu } from "@plane/ui"; import { CreateUpdateIssueModal } from "components/issues/modal"; +import { CreateUpdateDraftIssueModal } from "components/issues/draft-issue-modal"; import { ExistingIssuesListModal } from "components/core"; // lucide icons import { Minimize2, Maximize2, Circle, Plus } from "lucide-react"; @@ -51,6 +52,8 @@ export const HeaderGroupByCard: FC = observer((props) => { const router = useRouter(); const { workspaceSlug, projectId, moduleId, cycleId } = router.query; + const isDraftIssue = router.pathname.includes("draft-issue"); + const { setToastAlert } = useToast(); const renderExistingIssueModal = moduleId || cycleId; @@ -73,12 +76,21 @@ export const HeaderGroupByCard: FC = observer((props) => { return ( <> - setIsOpen(false)} - prePopulateData={issuePayload} - currentStore={currentStore} - /> + {isDraftIssue ? ( + setIsOpen(false)} + prePopulateData={issuePayload} + fieldsToShow={["all"]} + /> + ) : ( + setIsOpen(false)} + prePopulateData={issuePayload} + currentStore={currentStore} + /> + )} {renderExistingIssueModal && ( { } = useMobxStore(); const { currentProjectRole } = userStore; - const isEditingAllowed = [15, 20].includes(currentProjectRole || 0); + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; const issueIds = issueStore?.getIssuesIds || []; const issues = issueStore?.getIssues; @@ -147,7 +148,7 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { quickAddCallback={issueStore?.quickAddIssue} enableIssueQuickAdd={!!enableQuickAdd} isReadonly={!enableInlineEditing || !isEditingAllowed} - disableIssueCreation={!enableIssueCreation} + disableIssueCreation={!enableIssueCreation || !isEditingAllowed} currentStore={currentStore} addIssuesToView={addIssuesToView} /> diff --git a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx index c49d33d1e..24dbf435d 100644 --- a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; // lucide icons import { CircleDashed, Plus } from "lucide-react"; // components +import { CreateUpdateDraftIssueModal } from "components/issues/draft-issue-modal"; import { CreateUpdateIssueModal } from "components/issues/modal"; import { ExistingIssuesListModal } from "components/core"; import { CustomMenu } from "@plane/ui"; @@ -32,6 +33,8 @@ export const HeaderGroupByCard = observer( const [openExistingIssueListModal, setOpenExistingIssueListModal] = React.useState(false); + const isDraftIssue = router.pathname.includes("draft-issue"); + const { setToastAlert } = useToast(); const renderExistingIssueModal = moduleId || cycleId; @@ -90,12 +93,21 @@ export const HeaderGroupByCard = observer(
))} - setIsOpen(false)} - currentStore={currentStore} - prePopulateData={issuePayload} - /> + {isDraftIssue ? ( + setIsOpen(false)} + prePopulateData={issuePayload} + fieldsToShow={["all"]} + /> + ) : ( + setIsOpen(false)} + currentStore={currentStore} + prePopulateData={issuePayload} + /> + )} {renderExistingIssueModal && ( = observer( })} ) : ( - - + + )}
diff --git a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index a49af10ae..9f7fbdb7c 100644 --- a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -63,23 +63,23 @@ export const AllIssueLayoutRoot: React.FC = observer((props) => { const issueActions = { [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug) return; + const projectId = issue.project; + if (!workspaceSlug || !projectId) return; - await updateIssue(workspaceSlug, issue.project, issue.id, issue); + await updateIssue(workspaceSlug, projectId, issue.id, issue, currentIssueView); }, [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug) return; + const projectId = issue.project; + if (!workspaceSlug || !projectId) return; - await removeIssue(workspaceSlug, issue.project, issue.id); + await removeIssue(workspaceSlug, projectId, issue.id, currentIssueView); }, }; const handleIssues = useCallback( async (issue: IIssue, action: EIssueActions) => { - if (issueActions && action && issue) { - if (action === EIssueActions.UPDATE) await issueActions[action]!(issue); - if (action === EIssueActions.DELETE) await issueActions[action]!(issue); - } + if (action === EIssueActions.UPDATE) await issueActions[action]!(issue); + if (action === EIssueActions.DELETE) await issueActions[action]!(issue); }, [getIssues] ); diff --git a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx index db0aa37e1..e0b4c8cb5 100644 --- a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx @@ -18,6 +18,7 @@ import { observer } from "mobx-react-lite"; import { EFilterType, TUnGroupedIssues } from "store/issues/types"; import { EIssueActions } from "../types"; import { IQuickActionProps } from "../list/list-view-types"; +import { EUserWorkspaceRoles } from "constants/workspace"; interface IBaseSpreadsheetRoot { issueFiltersStore: @@ -49,7 +50,7 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { } = useMobxStore(); const { currentProjectRole } = userStore; - const isEditingAllowed = [15, 20].includes(currentProjectRole || 0); + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; const issuesResponse = issueStore.getIssues; const issueIds = (issueStore.getIssuesIds ?? []) as TUnGroupedIssues; diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx index 25b5139f5..0f7a138d4 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx @@ -85,14 +85,17 @@ export const SpreadsheetColumn: React.FC = (props) => { customButton={
+ {} + {propertyDetails.title} +
+
{activeSortingProperty === property && (
)} - {propertyDetails.title} +
-
} width="xl" diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index f18336e45..de69c2d4e 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -8,7 +8,7 @@ import { SpreadsheetIssuesColumn, SpreadsheetQuickAddIssueForm, } from "components/issues"; -import { Spinner } from "@plane/ui"; +import { Spinner, LayersIcon } from "@plane/ui"; // types import { IIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueLabel, IState, IUserLite } from "types"; import { EIssueActions } from "../types"; @@ -95,9 +95,14 @@ export const SpreadsheetView: React.FC = observer((props) => { >
{displayProperties.key && ( - ID + + #ID + )} - Issue + + + Issue +
{issues.map((issue, index) => diff --git a/web/components/issues/issue-peek-overview/issue-detail.tsx b/web/components/issues/issue-peek-overview/issue-detail.tsx index 60d20483e..3e90c8b8d 100644 --- a/web/components/issues/issue-peek-overview/issue-detail.tsx +++ b/web/components/issues/issue-peek-overview/issue-detail.tsx @@ -14,6 +14,7 @@ import { IIssue } from "types"; // services import { FileService } from "services/file.service"; import { useMobxStore } from "lib/mobx/store-provider"; +import { EUserWorkspaceRoles } from "constants/workspace"; const fileService = new FileService(); @@ -25,16 +26,27 @@ interface IPeekOverviewIssueDetails { issueUpdate: (issue: Partial) => void; issueReactionCreate: (reaction: string) => void; issueReactionRemove: (reaction: string) => void; + isSubmitting: "submitting" | "submitted" | "saved"; + setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; } export const PeekOverviewIssueDetails: FC = (props) => { - const { workspaceSlug, issue, issueReactions, user, issueUpdate, issueReactionCreate, issueReactionRemove } = props; + const { + workspaceSlug, + issue, + issueReactions, + user, + issueUpdate, + issueReactionCreate, + issueReactionRemove, + isSubmitting, + setIsSubmitting, + } = props; // store const { user: userStore } = useMobxStore(); const { currentProjectRole } = userStore; - const isAllowed = [15, 20].includes(currentProjectRole || 0); + const isAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; // states - const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); const [characterLimit, setCharacterLimit] = useState(false); // hooks const { setShowAlert } = useReloadConfirmations(); @@ -171,13 +183,6 @@ export const PeekOverviewIssueDetails: FC = (props) = /> )} /> -
- {isSubmitting === "submitting" ? "Saving..." : "Saved"} -
= observer((pro {/* parent */}
- +

Parent

diff --git a/web/components/issues/issue-peek-overview/root.tsx b/web/components/issues/issue-peek-overview/root.tsx index 5e2e0e3cc..09a5202b5 100644 --- a/web/components/issues/issue-peek-overview/root.tsx +++ b/web/components/issues/issue-peek-overview/root.tsx @@ -12,6 +12,7 @@ import { IssueView } from "./view"; import { copyUrlToClipboard } from "helpers/string.helper"; // types import { IIssue } from "types"; +import { EUserWorkspaceRoles } from "constants/workspace"; interface IIssuePeekOverview { workspaceSlug: string; @@ -118,7 +119,7 @@ export const IssuePeekOverview: FC = observer((props) => { } }; - const userRole = userStore.currentProjectRole ?? 5; + const userRole = userStore.currentProjectRole ?? EUserWorkspaceRoles.GUEST; return ( diff --git a/web/components/issues/issue-peek-overview/view.tsx b/web/components/issues/issue-peek-overview/view.tsx index 25b8884cf..a05ec9ac1 100644 --- a/web/components/issues/issue-peek-overview/view.tsx +++ b/web/components/issues/issue-peek-overview/view.tsx @@ -8,8 +8,7 @@ import { PeekOverviewIssueDetails } from "./issue-detail"; import { PeekOverviewProperties } from "./properties"; import { IssueComment } from "./activity"; import { Button, CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon, Spinner } from "@plane/ui"; -import { DeleteIssueModal } from "../delete-issue-modal"; -import { DeleteArchivedIssueModal } from "../delete-archived-issue-modal"; +import { DeleteIssueModal, DeleteArchivedIssueModal, IssueUpdateStatus } from "components/issues/"; // types import { IIssue } from "types"; // hooks @@ -93,6 +92,7 @@ export const IssueView: FC = observer((props) => { const [peekMode, setPeekMode] = useState("side-peek"); const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); const updateRoutePeekId = () => { if (issueId != peekIssueId) { @@ -216,33 +216,35 @@ export const IssueView: FC = observer((props) => {
)}
- -
- {issue?.created_by !== user?.id && - !issue?.assignees.includes(user?.id ?? "") && - !router.pathname.includes("[archivedIssueId]") && ( - - )} - - {!disableUserActions && ( - + )} + - )} + {!disableUserActions && ( + + )} +
@@ -261,6 +263,8 @@ export const IssueView: FC = observer((props) => {
)} setIsSubmitting(value)} + isSubmitting={isSubmitting} workspaceSlug={workspaceSlug} issue={issue} issueUpdate={issueUpdate} @@ -295,6 +299,8 @@ export const IssueView: FC = observer((props) => {
setIsSubmitting(value)} + isSubmitting={isSubmitting} workspaceSlug={workspaceSlug} issue={issue} issueReactions={issueReactions} diff --git a/web/components/issues/issue-update-status.tsx b/web/components/issues/issue-update-status.tsx new file mode 100644 index 000000000..e6852936e --- /dev/null +++ b/web/components/issues/issue-update-status.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { RefreshCw } from "lucide-react"; +// types +import { IIssue } from "types"; + +type Props = { + isSubmitting: "submitting" | "submitted" | "saved"; + issueDetail?: IIssue; +}; + +export const IssueUpdateStatus: React.FC = (props) => { + const { isSubmitting, issueDetail } = props; + return ( + <> + {issueDetail && ( +

+ {issueDetail.project_detail?.identifier}-{issueDetail.sequence_id} +

+ )} +
+ {isSubmitting !== "submitted" && isSubmitting !== "saved" && ( + + )} + {isSubmitting === "submitting" ? "Saving..." : "Saved"} +
+ + ); +}; diff --git a/web/components/issues/main-content.tsx b/web/components/issues/main-content.tsx index b8e1ce871..bd18cb73b 100644 --- a/web/components/issues/main-content.tsx +++ b/web/components/issues/main-content.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR, { mutate } from "swr"; +import { MinusCircle } from "lucide-react"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // services @@ -16,16 +17,17 @@ import { IssueAttachments, IssueDescriptionForm, IssueReaction, + IssueUpdateStatus, } from "components/issues"; +import { useState } from "react"; import { SubIssuesRoot } from "./sub-issues"; // ui -import { CustomMenu, LayersIcon } from "@plane/ui"; -// icons -import { MinusCircle } from "lucide-react"; +import { CustomMenu, LayersIcon, StateGroupIcon } from "@plane/ui"; // types import { IIssue, IIssueComment } from "types"; // fetch-keys import { PROJECT_ISSUES_ACTIVITY, SUB_ISSUES } from "constants/fetch-keys"; +import { EUserWorkspaceRoles } from "constants/workspace"; type Props = { issueDetails: IIssue; @@ -40,15 +42,25 @@ const issueCommentService = new IssueCommentService(); export const IssueMainContent: React.FC = observer((props) => { const { issueDetails, submitChanges, uneditable = false } = props; + // states + const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); + const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; const { setToastAlert } = useToast(); - const { user: userStore, project: projectStore } = useMobxStore(); + const { + user: userStore, + project: projectStore, + projectState: { states }, + } = useMobxStore(); const user = userStore.currentUser ?? undefined; const userRole = userStore.currentProjectRole; const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : undefined; + const currentIssueState = projectId + ? states[projectId.toString()]?.find((s) => s.id === issueDetails.state) + : undefined; const { data: siblingIssues } = useSWR( workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null, @@ -100,6 +112,8 @@ export const IssueMainContent: React.FC = observer((props) => { ); }; + const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER; + return ( <>
@@ -162,11 +176,23 @@ export const IssueMainContent: React.FC = observer((props) => {
) : null} +
+ {currentIssueState && ( + + )} + +
setIsSubmitting(value)} + isSubmitting={isSubmitting} workspaceSlug={workspaceSlug as string} issue={issueDetails} handleFormSubmit={submitChanges} - isAllowed={userRole === 20 || userRole === 15 || !uneditable} + isAllowed={isAllowed || !uneditable} /> diff --git a/web/components/issues/modal.tsx b/web/components/issues/modal.tsx index 3d5780c06..65ae1b12f 100644 --- a/web/components/issues/modal.tsx +++ b/web/components/issues/modal.tsx @@ -240,7 +240,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop if (handleSubmit) { await handleSubmit(res); } else { - currentIssueStore.fetchIssues(workspaceSlug, dataIdToUpdate, "mutation", viewId); + if (viewId) currentIssueStore.fetchIssues(workspaceSlug, dataIdToUpdate, "mutation", viewId); if (payload.cycle && payload.cycle !== "") await addIssueToCycle(res, payload.cycle); if (payload.module && payload.module !== "") await addIssueToModule(res, payload.module); diff --git a/web/components/issues/sidebar.tsx b/web/components/issues/sidebar.tsx index c0bb2da18..378b1e5c5 100644 --- a/web/components/issues/sidebar.tsx +++ b/web/components/issues/sidebar.tsx @@ -32,14 +32,15 @@ import { // ui import { CustomDatePicker } from "components/ui"; // icons -import { Bell, CalendarDays, LinkIcon, Plus, Signal, Tag, Trash2, Triangle, User2 } from "lucide-react"; -import { Button, ContrastIcon, DiceIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui"; +import { Bell, CalendarDays, LinkIcon, Plus, Signal, Tag, Trash2, Triangle, LayoutPanelTop } from "lucide-react"; +import { Button, ContrastIcon, DiceIcon, DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui"; // helpers import { copyTextToClipboard } from "helpers/string.helper"; // types import type { IIssue, IIssueLink, linkDetails } from "types"; // fetch-keys import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; +import { EUserWorkspaceRoles } from "constants/workspace"; type Props = { control: any; @@ -79,12 +80,15 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { const [linkModal, setLinkModal] = useState(false); const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState(null); - const { user: userStore } = useMobxStore(); + const { + user: userStore, + projectState: { states }, + } = useMobxStore(); const user = userStore.currentUser; const userRole = userStore.currentProjectRole; const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; + const { workspaceSlug, projectId, issueId, inboxIssueId } = router.query; const { isEstimateActive } = useEstimateOption(); @@ -245,7 +249,11 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { setLinkModal(true); }; - const isNotAllowed = userRole === 5 || userRole === 10; + const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER; + + const currentIssueState = projectId + ? states[projectId.toString()]?.find((s) => s.id === issueDetail?.state) + : undefined; return ( <> @@ -265,9 +273,20 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { )}
-

- {issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id} -

+
+ {currentIssueState ? ( + + ) : inboxIssueId ? ( + + ) : null} +

+ {issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id} +

+
{issueDetail?.created_by !== user?.id && !issueDetail?.assignees.includes(user?.id ?? "") && @@ -295,7 +314,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { )} - {!isNotAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && ( + {isAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && (
@@ -605,7 +624,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { issueDetails={issueDetail} labelList={issueDetail?.labels ?? []} submitChanges={submitChanges} - isNotAllowed={isNotAllowed} + isNotAllowed={!isAllowed} uneditable={uneditable ?? false} />
@@ -615,7 +634,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

Links

- {!isNotAllowed && ( + {isAllowed && ( )} diff --git a/web/components/project/project-settings-member-defaults.tsx b/web/components/project/project-settings-member-defaults.tsx index fd0f806c3..f45e1fcfa 100644 --- a/web/components/project/project-settings-member-defaults.tsx +++ b/web/components/project/project-settings-member-defaults.tsx @@ -15,6 +15,7 @@ import { Loader } from "@plane/ui"; import { IProject, IUserLite, IWorkspace } from "types"; // fetch-keys import { PROJECT_MEMBERS } from "constants/fetch-keys"; +import { EUserWorkspaceRoles } from "constants/workspace"; const defaultValues: Partial = { project_lead: null, @@ -29,7 +30,7 @@ export const ProjectSettingsMemberDefaults: React.FC = observer(() => { const { user: userStore, project: projectStore } = useMobxStore(); const { currentProjectDetails } = projectStore; const { currentProjectRole } = userStore; - const isAdmin = currentProjectRole === 20; + const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN; // hooks const { setToastAlert } = useToast(); // form info diff --git a/web/components/project/send-project-invitation-modal.tsx b/web/components/project/send-project-invitation-modal.tsx index 8ad55ebec..dab389f74 100644 --- a/web/components/project/send-project-invitation-modal.tsx +++ b/web/components/project/send-project-invitation-modal.tsx @@ -15,7 +15,7 @@ import useToast from "hooks/use-toast"; // types import { IProjectMember, TUserProjectRole } from "types"; // constants -import { ROLE } from "constants/workspace"; +import { EUserWorkspaceRoles, ROLE } from "constants/workspace"; type Props = { isOpen: boolean; @@ -246,7 +246,8 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { width="w-full" > {Object.entries(ROLE).map(([key, label]) => { - if (parseInt(key) > (currentProjectRole ?? 5)) return null; + if (parseInt(key) > (currentProjectRole ?? EUserWorkspaceRoles.GUEST)) + return null; return ( diff --git a/web/components/project/settings/features-list.tsx b/web/components/project/settings/features-list.tsx index d562f19f7..b8a776bb0 100644 --- a/web/components/project/settings/features-list.tsx +++ b/web/components/project/settings/features-list.tsx @@ -9,6 +9,7 @@ import { useMobxStore } from "lib/mobx/store-provider"; import useToast from "hooks/use-toast"; // types import { IProject } from "types"; +import { EUserWorkspaceRoles } from "constants/workspace"; type Props = {}; @@ -56,7 +57,7 @@ export const ProjectFeaturesList: FC = observer(() => { user: { currentUser, currentProjectRole }, trackEvent: { setTrackElement, postHogEventTracker }, } = useMobxStore(); - const isAdmin = currentProjectRole === 20; + const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN; // hooks const { setToastAlert } = useToast(); @@ -97,7 +98,7 @@ export const ProjectFeaturesList: FC = observer(() => { project_id: currentProjectDetails?.id, project_name: currentProjectDetails?.name, project_identifier: currentProjectDetails?.identifier, - enabled: !currentProjectDetails?.[feature.property as keyof IProject] + enabled: !currentProjectDetails?.[feature.property as keyof IProject], }); handleSubmit({ [feature.property]: !currentProjectDetails?.[feature.property as keyof IProject], diff --git a/web/components/project/sidebar-list-item.tsx b/web/components/project/sidebar-list-item.tsx index 488ae571a..5ad757e5f 100644 --- a/web/components/project/sidebar-list-item.tsx +++ b/web/components/project/sidebar-list-item.tsx @@ -284,7 +284,7 @@ export const ProjectSidebarListItem: React.FC = observer((props) => {
- Leave Project + Leave project
)} diff --git a/web/components/workspace/settings/members-list-item.tsx b/web/components/workspace/settings/members-list-item.tsx index 7536e78c9..751fc14e1 100644 --- a/web/components/workspace/settings/members-list-item.tsx +++ b/web/components/workspace/settings/members-list-item.tsx @@ -243,7 +243,7 @@ export const WorkspaceMembersListItem: FC = observer((props) => { : "opacity-0 pointer-events-none" } > - +
diff --git a/web/constants/spreadsheet.ts b/web/constants/spreadsheet.ts index 603e280f5..00a59eddb 100644 --- a/web/constants/spreadsheet.ts +++ b/web/constants/spreadsheet.ts @@ -1,4 +1,8 @@ import { TIssueOrderByOptions } from "types"; +import { LayersIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui"; +import { CalendarDays, Link2, Signal, Tag, Triangle, Paperclip } from "lucide-react"; +import { FC } from "react"; +import { ISvgIcons } from "@plane/ui/src/icons/type"; export const SPREADSHEET_PROPERTY_DETAILS: { [key: string]: { @@ -7,6 +11,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { ascendingOrderTitle: string; descendingOrderKey: TIssueOrderByOptions; descendingOrderTitle: string; + icon: FC; }; } = { assignee: { @@ -15,6 +20,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { ascendingOrderTitle: "A", descendingOrderKey: "-assignees__first_name", descendingOrderTitle: "Z", + icon: UserGroupIcon, }, created_on: { title: "Created on", @@ -22,6 +28,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { ascendingOrderTitle: "New", descendingOrderKey: "created_at", descendingOrderTitle: "Old", + icon: CalendarDays, }, due_date: { title: "Due date", @@ -29,6 +36,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { ascendingOrderTitle: "New", descendingOrderKey: "target_date", descendingOrderTitle: "Old", + icon: CalendarDays, }, estimate: { title: "Estimate", @@ -36,6 +44,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { ascendingOrderTitle: "Low", descendingOrderKey: "-estimate_point", descendingOrderTitle: "High", + icon: Triangle, }, labels: { title: "Labels", @@ -43,6 +52,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { ascendingOrderTitle: "A", descendingOrderKey: "-labels__name", descendingOrderTitle: "Z", + icon: Tag, }, priority: { title: "Priority", @@ -50,6 +60,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { ascendingOrderTitle: "None", descendingOrderKey: "-priority", descendingOrderTitle: "Urgent", + icon: Signal, }, start_date: { title: "Start date", @@ -57,6 +68,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { ascendingOrderTitle: "New", descendingOrderKey: "start_date", descendingOrderTitle: "Old", + icon: CalendarDays, }, state: { title: "State", @@ -64,6 +76,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { ascendingOrderTitle: "A", descendingOrderKey: "-state__name", descendingOrderTitle: "Z", + icon: DoubleCircleIcon, }, updated_on: { title: "Updated on", @@ -71,6 +84,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { ascendingOrderTitle: "New", descendingOrderKey: "updated_at", descendingOrderTitle: "Old", + icon: CalendarDays, }, link: { title: "Link", @@ -78,6 +92,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { ascendingOrderTitle: "Most", descendingOrderKey: "link_count", descendingOrderTitle: "Least", + icon: Link2, }, attachment_count: { title: "Attachment", @@ -85,6 +100,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { ascendingOrderTitle: "Most", descendingOrderKey: "attachment_count", descendingOrderTitle: "Least", + icon: Paperclip, }, sub_issue_count: { title: "Sub-issue", @@ -92,5 +108,6 @@ export const SPREADSHEET_PROPERTY_DETAILS: { ascendingOrderTitle: "Most", descendingOrderKey: "sub_issues_count", descendingOrderTitle: "Least", + icon: LayersIcon, }, }; diff --git a/web/helpers/user.helper.ts b/web/helpers/user.helper.ts index 569da6018..7af73b021 100644 --- a/web/helpers/user.helper.ts +++ b/web/helpers/user.helper.ts @@ -1,12 +1,14 @@ -export const getUserRole = (role: number) => { +import { EUserWorkspaceRoles } from "constants/workspace"; + +export const getUserRole = (role: EUserWorkspaceRoles) => { switch (role) { - case 5: + case EUserWorkspaceRoles.GUEST: return "GUEST"; - case 10: + case EUserWorkspaceRoles.VIEWER: return "VIEWER"; - case 15: + case EUserWorkspaceRoles.MEMBER: return "MEMBER"; - case 20: + case EUserWorkspaceRoles.ADMIN: return "ADMIN"; } }; diff --git a/web/hooks/use-sign-in-redirection.ts b/web/hooks/use-sign-in-redirection.ts new file mode 100644 index 000000000..1863e510e --- /dev/null +++ b/web/hooks/use-sign-in-redirection.ts @@ -0,0 +1,74 @@ +import { useCallback, useState } from "react"; +import { useRouter } from "next/router"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// types +import { IUser, IUserSettings } from "types"; + +type UseSignInRedirectionProps = { + error: any | null; + isRedirecting: boolean; + handleRedirection: () => Promise; +}; + +const useSignInRedirection = (): UseSignInRedirectionProps => { + // states + const [isRedirecting, setIsRedirecting] = useState(true); + const [error, setError] = useState(null); + // router + const router = useRouter(); + const { next_url } = router.query; + // mobx store + const { + user: { fetchCurrentUser, fetchCurrentUserSettings }, + } = useMobxStore(); + + const handleSignInRedirection = useCallback( + async (user: IUser) => { + // if the user is not onboarded, redirect them to the onboarding page + if (!user.is_onboarded) { + router.push("/onboarding"); + return; + } + // if next_url is provided, redirect the user to that url + if (next_url) { + router.push(next_url.toString()); + return; + } + + // if the user is onboarded, fetch their last workspace details + await fetchCurrentUserSettings() + .then((userSettings: IUserSettings) => { + const workspaceSlug = + userSettings?.workspace?.last_workspace_slug || userSettings?.workspace?.fallback_workspace_slug; + if (workspaceSlug) router.push(`/${workspaceSlug}`); + else router.push("/profile"); + }) + .catch((err) => setError(err)); + }, + [fetchCurrentUserSettings, router, next_url] + ); + + const updateUserInfo = useCallback(async () => { + setIsRedirecting(true); + + await fetchCurrentUser() + .then(async (user) => { + await handleSignInRedirection(user) + .catch((err) => setError(err)) + .finally(() => setIsRedirecting(false)); + }) + .catch((err) => { + setError(err); + setIsRedirecting(false); + }); + }, [fetchCurrentUser, handleSignInRedirection]); + + return { + error, + isRedirecting, + handleRedirection: updateUserInfo, + }; +}; + +export default useSignInRedirection; diff --git a/web/layouts/settings-layout/project/layout.tsx b/web/layouts/settings-layout/project/layout.tsx index 7a0bee0eb..d823cbe70 100644 --- a/web/layouts/settings-layout/project/layout.tsx +++ b/web/layouts/settings-layout/project/layout.tsx @@ -1,15 +1,27 @@ import { FC, ReactNode } from "react"; // components import { ProjectSettingsSidebar } from "./sidebar"; +import { useMobxStore } from "lib/mobx/store-provider"; +import { EUserWorkspaceRoles } from "constants/workspace"; +import { NotAuthorizedView } from "components/auth-screens"; +import { observer } from "mobx-react-lite"; export interface IProjectSettingLayout { children: ReactNode; } -export const ProjectSettingLayout: FC = (props) => { +export const ProjectSettingLayout: FC = observer((props) => { const { children } = props; - return ( + const { + user: { currentProjectRole }, + } = useMobxStore(); + + const restrictViewSettings = currentProjectRole && currentProjectRole <= EUserWorkspaceRoles.VIEWER; + + return restrictViewSettings ? ( + + ) : (
@@ -17,4 +29,4 @@ export const ProjectSettingLayout: FC = (props) => { {children}
); -}; +}); diff --git a/web/pages/[workspaceSlug]/profile/[userId]/assigned.tsx b/web/pages/[workspaceSlug]/profile/[userId]/assigned.tsx index a2c09dd98..cd2eb09bc 100644 --- a/web/pages/[workspaceSlug]/profile/[userId]/assigned.tsx +++ b/web/pages/[workspaceSlug]/profile/[userId]/assigned.tsx @@ -1,5 +1,4 @@ import React, { ReactElement } from "react"; -import { observer } from "mobx-react-lite"; // layouts import { AppLayout } from "layouts/app-layout"; import { ProfileAuthWrapper } from "layouts/user-profile-layout"; @@ -9,7 +8,7 @@ import { UserProfileHeader } from "components/headers"; import { NextPageWithLayout } from "types/app"; import { ProfileIssuesPage } from "components/profile/profile-issues"; -const ProfileAssignedIssuesPage: NextPageWithLayout = observer(() => ); +const ProfileAssignedIssuesPage: NextPageWithLayout = () => ; ProfileAssignedIssuesPage.getLayout = function getLayout(page: ReactElement) { return ( diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx index 7889deb5a..6ae324495 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx @@ -14,6 +14,7 @@ import { ProjectSettingHeader } from "components/headers"; // types import { NextPageWithLayout } from "types/app"; import { IProject } from "types"; +import { EUserWorkspaceRoles } from "constants/workspace"; const AutomationSettingsPage: NextPageWithLayout = observer(() => { const router = useRouter(); @@ -39,7 +40,7 @@ const AutomationSettingsPage: NextPageWithLayout = observer(() => { }); }; - const isAdmin = currentProjectRole === 20; + const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN; return (
diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/estimates.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/estimates.tsx index 783243c4a..ba4475148 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/estimates.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/estimates.tsx @@ -7,12 +7,23 @@ import { ProjectSettingHeader } from "components/headers"; import { EstimatesList } from "components/estimates"; // types import { NextPageWithLayout } from "types/app"; +import { useMobxStore } from "lib/mobx/store-provider"; +import { EUserWorkspaceRoles } from "constants/workspace"; +import { observer } from "mobx-react-lite"; -const EstimatesSettingsPage: NextPageWithLayout = () => ( -
- -
-); +const EstimatesSettingsPage: NextPageWithLayout = observer(() => { + const { + user: { currentProjectRole }, + } = useMobxStore(); + + const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN; + + return ( +
+ +
+ ); +}); EstimatesSettingsPage.getLayout = function getLayout(page: ReactElement) { return ( diff --git a/web/pages/accounts/password.tsx b/web/pages/accounts/password.tsx index f85840cda..db6c28a7e 100644 --- a/web/pages/accounts/password.tsx +++ b/web/pages/accounts/password.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useCallback } from "react"; +import { ReactElement } from "react"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -9,6 +9,7 @@ import { Controller, useForm } from "react-hook-form"; import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; +import useSignInRedirection from "hooks/use-sign-in-redirection"; // layouts import DefaultLayout from "layouts/default-layout"; // ui @@ -20,9 +21,6 @@ import latestFeatures from "public/onboarding/onboarding-pages.svg"; import { checkEmailValidity } from "helpers/string.helper"; // type import { NextPageWithLayout } from "types/app"; -import { useMobxStore } from "lib/mobx/store-provider"; -// types -import { IUser, IUserSettings } from "types"; type TResetPasswordFormValues = { email: string; @@ -45,10 +43,8 @@ const HomePage: NextPageWithLayout = () => { const { resolvedTheme } = useTheme(); // toast const { setToastAlert } = useToast(); - // mobx store - const { - user: { fetchCurrentUser, fetchCurrentUserSettings }, - } = useMobxStore(); + // sign in redirection hook + const { handleRedirection } = useSignInRedirection(); // form info const { control, @@ -61,31 +57,6 @@ const HomePage: NextPageWithLayout = () => { }, }); - const handleSignInRedirection = useCallback( - async (user: IUser) => { - // if the user is not onboarded, redirect them to the onboarding page - if (!user.is_onboarded) { - router.push("/onboarding"); - return; - } - - // if the user is onboarded, fetch their last workspace details - await fetchCurrentUserSettings().then((userSettings: IUserSettings) => { - const workspaceSlug = - userSettings?.workspace?.last_workspace_slug || userSettings?.workspace?.fallback_workspace_slug; - if (workspaceSlug) router.push(`/${workspaceSlug}`); - else router.push("/profile"); - }); - }, - [fetchCurrentUserSettings, router] - ); - - const mutateUserInfo = useCallback(async () => { - await fetchCurrentUser().then(async (user) => { - await handleSignInRedirection(user); - }); - }, [fetchCurrentUser, handleSignInRedirection]); - const handleResetPassword = async (formData: TResetPasswordFormValues) => { if (!uidb64 || !token || !email) return; @@ -95,7 +66,7 @@ const HomePage: NextPageWithLayout = () => { await authService .resetPassword(uidb64.toString(), token.toString(), payload) - .then(() => mutateUserInfo()) + .then(() => handleRedirection()) .catch((err) => setToastAlert({ type: "error", diff --git a/web/pages/invitations/index.tsx b/web/pages/invitations/index.tsx index 80738907a..55c0b89aa 100644 --- a/web/pages/invitations/index.tsx +++ b/web/pages/invitations/index.tsx @@ -213,7 +213,7 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { image={emptyInvitation} primaryButton={{ text: "Back to dashboard", - onClick: () => router.push(`/${redirectWorkspaceSlug}`), + onClick: () => router.push("/"), }} />
diff --git a/web/pages/onboarding/index.tsx b/web/pages/onboarding/index.tsx index df7df99f4..94c7b751a 100644 --- a/web/pages/onboarding/index.tsx +++ b/web/pages/onboarding/index.tsx @@ -52,6 +52,10 @@ const OnboardingPage: NextPageWithLayout = observer(() => { }, }); + useSWR(`USER_WORKSPACES_LIST`, () => workspaceStore.fetchWorkspaces(), { + shouldRetryOnError: false, + }); + const { data: invitations } = useSWR("USER_WORKSPACE_INVITATIONS_LIST", () => workspaceService.userWorkspaceInvitations() ); @@ -88,6 +92,19 @@ const OnboardingPage: NextPageWithLayout = observer(() => { const onboardingStep = user.onboarding_step; + if (!onboardingStep.workspace_join && !onboardingStep.workspace_create && workspaces && workspaces?.length > 0) { + await updateCurrentUser({ + onboarding_step: { + ...user.onboarding_step, + workspace_join: true, + workspace_create: true, + }, + last_workspace_id: workspaces[0].id, + }); + setStep(2); + return; + } + if (!onboardingStep.workspace_join && !onboardingStep.workspace_create && step !== 1) setStep(1); if (onboardingStep.workspace_join || onboardingStep.workspace_create) { diff --git a/web/public/onboarding/onboarding-pages.svg b/web/public/onboarding/onboarding-pages.svg index 93118ebe0..6b0e77a3c 100644 --- a/web/public/onboarding/onboarding-pages.svg +++ b/web/public/onboarding/onboarding-pages.svg @@ -1,62 +1,62 @@ - - - + + + - - + + - + - + - + - - + + - + - + - + - + - - + + - + - - + + diff --git a/web/services/auth.service.ts b/web/services/auth.service.ts index 47325cf30..175fe8a76 100644 --- a/web/services/auth.service.ts +++ b/web/services/auth.service.ts @@ -135,7 +135,7 @@ export class AuthService extends APIService { } async instanceMagicSignIn(data: any): Promise { - const response = await this.post("/api/licenses/instances/admins/magic-sign-in/", data); + const response = await this.post("/api/licenses/instances/admins/magic-sign-in/", data, { headers: {} }); if (response?.status === 200) { this.setAccessToken(response?.data?.access_token); this.setRefreshToken(response?.data?.refresh_token); diff --git a/web/store/inbox/inbox_filters.store.ts b/web/store/inbox/inbox_filters.store.ts index c5758ad9d..abd125939 100644 --- a/web/store/inbox/inbox_filters.store.ts +++ b/web/store/inbox/inbox_filters.store.ts @@ -5,6 +5,7 @@ import { RootStore } from "../root"; import { InboxService } from "services/inbox.service"; // types import { IInbox, IInboxFilterOptions, IInboxQueryParams } from "types"; +import { EUserWorkspaceRoles } from "constants/workspace"; export interface IInboxFiltersStore { // states @@ -132,8 +133,8 @@ export class InboxFiltersStore implements IInboxFiltersStore { }; }); - const userRole = this.rootStore.user?.projectMemberInfo?.[projectId]?.role || 0; - if (userRole > 10) { + const userRole = this.rootStore.user?.currentProjectRole || EUserWorkspaceRoles.GUEST; + if (userRole > EUserWorkspaceRoles.VIEWER) { await this.inboxService.patchInbox(workspaceSlug, projectId, inboxId, { view_props: newViewProps }); } } catch (error) { diff --git a/web/store/issues/base-issue-kanban-helper.store.ts b/web/store/issues/base-issue-kanban-helper.store.ts index 62b25fe22..e21c85e84 100644 --- a/web/store/issues/base-issue-kanban-helper.store.ts +++ b/web/store/issues/base-issue-kanban-helper.store.ts @@ -118,8 +118,6 @@ export class KanBanHelpers implements IKanBanHelpers { const [removed] = sourceIssues.splice(source.index, 1); - console.log("removed", removed); - if (removed) { if (viewId) store?.removeIssue(workspaceSlug, projectId, removed, viewId); else store?.removeIssue(workspaceSlug, projectId, removed); diff --git a/web/store/issues/global/filter.store.ts b/web/store/issues/global/filter.store.ts index b495bb816..4302acc76 100644 --- a/web/store/issues/global/filter.store.ts +++ b/web/store/issues/global/filter.store.ts @@ -6,6 +6,7 @@ import { EFilterType } from "store/issues/types"; import { IssueFilterBaseStore } from "../project-issues/base-issue-filter.store"; // helpers import { handleIssueQueryParamsByLayout } from "helpers/issue.helper"; +import { isNil } from "../project-issues/utils"; // services import { WorkspaceService } from "services/workspace.service"; @@ -377,6 +378,12 @@ export class GlobalIssuesFilterStore extends IssueFilterBaseStore implements IGl start_date: userFilters?.filters?.start_date || undefined, target_date: userFilters?.filters?.target_date || undefined, type: userFilters?.displayFilters?.type || undefined, + show_empty_groups: isNil(userFilters?.displayFilters?.show_empty_groups) + ? true + : userFilters?.displayFilters?.show_empty_groups, + start_target_date: isNil(userFilters?.displayFilters?.start_target_date) + ? true + : userFilters?.displayFilters?.start_target_date, sub_issue: false, }; diff --git a/web/store/issues/profile/filter.store.ts b/web/store/issues/profile/filter.store.ts index 0b684f7a7..dcb760d1a 100644 --- a/web/store/issues/profile/filter.store.ts +++ b/web/store/issues/profile/filter.store.ts @@ -6,6 +6,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption import { EFilterType } from "store/issues/types"; import { handleIssueQueryParamsByLayout } from "helpers/issue.helper"; import { IssueFilterBaseStore } from "../project-issues/base-issue-filter.store"; +import { isNil } from "../project-issues/utils"; interface IProjectIssuesFiltersOptions { filters: IIssueFilterOptions; @@ -285,9 +286,13 @@ export class ProfileIssuesFilterStore extends IssueFilterBaseStore implements IP start_date: userFilters?.filters?.start_date || undefined, target_date: userFilters?.filters?.target_date || undefined, type: userFilters?.displayFilters?.type || undefined, - sub_issue: userFilters?.displayFilters?.sub_issue || true, - show_empty_groups: userFilters?.displayFilters?.show_empty_groups || true, - start_target_date: userFilters?.displayFilters?.start_target_date || true, + sub_issue: isNil(userFilters?.displayFilters?.sub_issue) ? true : userFilters?.displayFilters?.sub_issue, + show_empty_groups: isNil(userFilters?.displayFilters?.show_empty_groups) + ? true + : userFilters?.displayFilters?.show_empty_groups, + start_target_date: isNil(userFilters?.displayFilters?.start_target_date) + ? true + : userFilters?.displayFilters?.start_target_date, }; const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "profile_issues"); diff --git a/web/store/issues/profile/issue.store.ts b/web/store/issues/profile/issue.store.ts index 37b9f3085..eb02796c6 100644 --- a/web/store/issues/profile/issue.store.ts +++ b/web/store/issues/profile/issue.store.ts @@ -28,7 +28,6 @@ export interface IProfileIssuesStore { workspaceSlug: string, userId: string, loadType: TLoader, - _?: string, type?: "assigned" | "created" | "subscribed" ) => Promise; createIssue: (workspaceSlug: string, userId: string, data: Partial) => Promise; @@ -151,7 +150,6 @@ export class ProfileIssuesStore extends IssueBaseStore implements IProfileIssues workspaceSlug: string, userId: string, loadType: TLoader = "init-loader", - _?: string, type?: "assigned" | "created" | "subscribed" ) => { try { diff --git a/web/store/issues/project-issues/archived/filter.store.ts b/web/store/issues/project-issues/archived/filter.store.ts index 77e30c4fc..0b3ce15a9 100644 --- a/web/store/issues/project-issues/archived/filter.store.ts +++ b/web/store/issues/project-issues/archived/filter.store.ts @@ -8,6 +8,7 @@ import { handleIssueQueryParamsByLayout } from "helpers/issue.helper"; import { RootStore } from "store/root"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueParams } from "types"; import { EFilterType } from "store/issues/types"; +import { isNil } from "../utils"; interface IProjectIssuesFilters { filters: IIssueFilterOptions | undefined; @@ -78,9 +79,13 @@ export class ProjectArchivedIssuesFilterStore start_date: userFilters?.filters?.start_date || undefined, target_date: userFilters?.filters?.target_date || undefined, type: userFilters?.displayFilters?.type || undefined, - sub_issue: userFilters?.displayFilters?.sub_issue || true, - show_empty_groups: userFilters?.displayFilters?.show_empty_groups || true, - start_target_date: userFilters?.displayFilters?.start_target_date || true, + sub_issue: isNil(userFilters?.displayFilters?.sub_issue) ? true : userFilters?.displayFilters?.sub_issue, + show_empty_groups: isNil(userFilters?.displayFilters?.show_empty_groups) + ? true + : userFilters?.displayFilters?.show_empty_groups, + start_target_date: isNil(userFilters?.displayFilters?.start_target_date) + ? true + : userFilters?.displayFilters?.start_target_date, }; const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues"); diff --git a/web/store/issues/project-issues/cycle/filter.store.ts b/web/store/issues/project-issues/cycle/filter.store.ts index 6e73d7613..b78999d7e 100644 --- a/web/store/issues/project-issues/cycle/filter.store.ts +++ b/web/store/issues/project-issues/cycle/filter.store.ts @@ -11,6 +11,7 @@ import { handleIssueQueryParamsByLayout } from "helpers/issue.helper"; import { RootStore } from "store/root"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueParams } from "types"; import { EFilterType } from "store/issues/types"; +import { isNil } from "../utils"; interface ICycleIssuesFilterOptions { filters: IIssueFilterOptions; @@ -118,9 +119,13 @@ export class CycleIssuesFilterStore extends IssueFilterBaseStore implements ICyc start_date: userFilters?.filters?.start_date || undefined, target_date: userFilters?.filters?.target_date || undefined, type: userFilters?.displayFilters?.type || undefined, - sub_issue: userFilters?.displayFilters?.sub_issue || true, - show_empty_groups: userFilters?.displayFilters?.show_empty_groups || true, - start_target_date: userFilters?.displayFilters?.start_target_date || true, + sub_issue: isNil(userFilters?.displayFilters?.sub_issue) ? true : userFilters?.displayFilters?.sub_issue, + show_empty_groups: isNil(userFilters?.displayFilters?.show_empty_groups) + ? true + : userFilters?.displayFilters?.show_empty_groups, + start_target_date: isNil(userFilters?.displayFilters?.start_target_date) + ? true + : userFilters?.displayFilters?.start_target_date, }; const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues"); diff --git a/web/store/issues/project-issues/cycle/issue.store.ts b/web/store/issues/project-issues/cycle/issue.store.ts index 341013ce4..a951914a1 100644 --- a/web/store/issues/project-issues/cycle/issue.store.ts +++ b/web/store/issues/project-issues/cycle/issue.store.ts @@ -62,7 +62,14 @@ export interface ICycleIssuesStore { issueId: string, issueBridgeId: string ) => Promise; - + transferIssuesFromCycle: ( + workspaceSlug: string, + projectId: string, + cycleId: string, + payload: { + new_cycle_id: string; + } + ) => Promise; viewFlags: ViewFlags; } @@ -103,6 +110,7 @@ export class CycleIssuesStore extends IssueBaseStore implements ICycleIssuesStor quickAddIssue: action, addIssueToCycle: action, removeIssueFromCycle: action, + transferIssuesFromCycle: action, }); this.rootStore = _rootStore; @@ -348,4 +356,28 @@ export class CycleIssuesStore extends IssueBaseStore implements ICycleIssuesStor throw error; } }; + + transferIssuesFromCycle = async ( + workspaceSlug: string, + projectId: string, + cycleId: string, + payload: { + new_cycle_id: string; + } + ) => { + try { + const response = await this.cycleService.transferIssues( + workspaceSlug as string, + projectId as string, + cycleId as string, + payload + ); + await this.fetchIssues(workspaceSlug, projectId, "mutation", cycleId); + + return response; + } catch (error) { + this.fetchIssues(workspaceSlug, projectId, "mutation", cycleId); + throw error; + } + }; } diff --git a/web/store/issues/project-issues/draft/filter.store.ts b/web/store/issues/project-issues/draft/filter.store.ts index 7cfca229b..0c0a3ba37 100644 --- a/web/store/issues/project-issues/draft/filter.store.ts +++ b/web/store/issues/project-issues/draft/filter.store.ts @@ -8,6 +8,7 @@ import { handleIssueQueryParamsByLayout } from "helpers/issue.helper"; import { RootStore } from "store/root"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueParams } from "types"; import { EFilterType } from "store/issues/types"; +import { isNil } from "../utils"; interface IProjectIssuesFilters { filters: IIssueFilterOptions | undefined; @@ -75,9 +76,13 @@ export class ProjectDraftIssuesFilterStore extends IssueFilterBaseStore implemen start_date: userFilters?.filters?.start_date || undefined, target_date: userFilters?.filters?.target_date || undefined, type: userFilters?.displayFilters?.type || undefined, - sub_issue: userFilters?.displayFilters?.sub_issue || true, - show_empty_groups: userFilters?.displayFilters?.show_empty_groups || true, - start_target_date: userFilters?.displayFilters?.start_target_date || true, + sub_issue: isNil(userFilters?.displayFilters?.sub_issue) ? true : userFilters?.displayFilters?.sub_issue, + show_empty_groups: isNil(userFilters?.displayFilters?.show_empty_groups) + ? true + : userFilters?.displayFilters?.show_empty_groups, + start_target_date: isNil(userFilters?.displayFilters?.start_target_date) + ? true + : userFilters?.displayFilters?.start_target_date, }; const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues"); diff --git a/web/store/issues/project-issues/draft/issue.store.ts b/web/store/issues/project-issues/draft/issue.store.ts index 0333c066c..ddcebc3f0 100644 --- a/web/store/issues/project-issues/draft/issue.store.ts +++ b/web/store/issues/project-issues/draft/issue.store.ts @@ -36,7 +36,7 @@ export class ProjectDraftIssuesStore extends IssueBaseStore implements IProjectD //viewData viewFlags = { enableQuickAdd: false, - enableIssueCreation: false, + enableIssueCreation: true, enableInlineEditing: false, }; diff --git a/web/store/issues/project-issues/module/filter.store.ts b/web/store/issues/project-issues/module/filter.store.ts index a30103314..103528ba8 100644 --- a/web/store/issues/project-issues/module/filter.store.ts +++ b/web/store/issues/project-issues/module/filter.store.ts @@ -11,6 +11,7 @@ import { handleIssueQueryParamsByLayout } from "helpers/issue.helper"; import { RootStore } from "store/root"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueParams } from "types"; import { EFilterType } from "store/issues/types"; +import { isNil } from "../utils"; interface IModuleIssuesFilterOptions { filters: IIssueFilterOptions; @@ -118,9 +119,13 @@ export class ModuleIssuesFilterStore extends IssueFilterBaseStore implements IMo start_date: userFilters?.filters?.start_date || undefined, target_date: userFilters?.filters?.target_date || undefined, type: userFilters?.displayFilters?.type || undefined, - sub_issue: userFilters?.displayFilters?.sub_issue || true, - show_empty_groups: userFilters?.displayFilters?.show_empty_groups || true, - start_target_date: userFilters?.displayFilters?.start_target_date || true, + sub_issue: isNil(userFilters?.displayFilters?.sub_issue) ? true : userFilters?.displayFilters?.sub_issue, + show_empty_groups: isNil(userFilters?.displayFilters?.show_empty_groups) + ? true + : userFilters?.displayFilters?.show_empty_groups, + start_target_date: isNil(userFilters?.displayFilters?.start_target_date) + ? true + : userFilters?.displayFilters?.start_target_date, }; const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues"); diff --git a/web/store/issues/project-issues/project-view/filter.store.ts b/web/store/issues/project-issues/project-view/filter.store.ts index 5aa25f604..215e3749f 100644 --- a/web/store/issues/project-issues/project-view/filter.store.ts +++ b/web/store/issues/project-issues/project-view/filter.store.ts @@ -11,6 +11,7 @@ import { handleIssueQueryParamsByLayout } from "helpers/issue.helper"; import { RootStore } from "store/root"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueParams } from "types"; import { EFilterType } from "store/issues/types"; +import { isNil } from "../utils"; interface IViewIssuesFilterOptions { filters: IIssueFilterOptions; @@ -118,9 +119,13 @@ export class ViewIssuesFilterStore extends IssueFilterBaseStore implements IView start_date: userFilters?.filters?.start_date || undefined, target_date: userFilters?.filters?.target_date || undefined, type: userFilters?.displayFilters?.type || undefined, - sub_issue: userFilters?.displayFilters?.sub_issue || true, - show_empty_groups: userFilters?.displayFilters?.show_empty_groups || true, - start_target_date: userFilters?.displayFilters?.start_target_date || true, + sub_issue: isNil(userFilters?.displayFilters?.sub_issue) ? true : userFilters?.displayFilters?.sub_issue, + show_empty_groups: isNil(userFilters?.displayFilters?.show_empty_groups) + ? true + : userFilters?.displayFilters?.show_empty_groups, + start_target_date: isNil(userFilters?.displayFilters?.start_target_date) + ? true + : userFilters?.displayFilters?.start_target_date, }; const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues"); diff --git a/web/store/issues/project-issues/project/filter.store.ts b/web/store/issues/project-issues/project/filter.store.ts index bb64b5784..8caf189ad 100644 --- a/web/store/issues/project-issues/project/filter.store.ts +++ b/web/store/issues/project-issues/project/filter.store.ts @@ -8,6 +8,7 @@ import { handleIssueQueryParamsByLayout } from "helpers/issue.helper"; import { RootStore } from "store/root"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueParams } from "types"; import { EFilterType } from "store/issues/types"; +import { isNil } from "../utils"; interface IProjectIssuesFilters { filters: IIssueFilterOptions | undefined; @@ -75,9 +76,13 @@ export class ProjectIssuesFilterStore extends IssueFilterBaseStore implements IP start_date: userFilters?.filters?.start_date || undefined, target_date: userFilters?.filters?.target_date || undefined, type: userFilters?.displayFilters?.type || undefined, - sub_issue: userFilters?.displayFilters?.sub_issue || true, - show_empty_groups: userFilters?.displayFilters?.show_empty_groups || true, - start_target_date: userFilters?.displayFilters?.start_target_date || true, + sub_issue: isNil(userFilters?.displayFilters?.sub_issue) ? true : userFilters?.displayFilters?.sub_issue, + show_empty_groups: isNil(userFilters?.displayFilters?.show_empty_groups) + ? true + : userFilters?.displayFilters?.show_empty_groups, + start_target_date: isNil(userFilters?.displayFilters?.start_target_date) + ? true + : userFilters?.displayFilters?.start_target_date, }; const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues"); diff --git a/web/store/issues/project-issues/utils.ts b/web/store/issues/project-issues/utils.ts new file mode 100644 index 000000000..11eb6b90d --- /dev/null +++ b/web/store/issues/project-issues/utils.ts @@ -0,0 +1,5 @@ +export const isNil = (value: any) => { + if (value === undefined || value === null) return true; + + return false; +};