Merge branch 'develop' of github.com:makeplane/plane into chore/space_sign_in_improvement

This commit is contained in:
Anmol Singh Bhatia 2023-12-05 17:38:13 +05:30
commit b5ad8d282b
152 changed files with 2143 additions and 1394 deletions

View File

@ -14,6 +14,11 @@ PGHOST="plane-db"
PGDATABASE="plane" PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Oauth variables
GOOGLE_CLIENT_ID=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# Redis Settings # Redis Settings
REDIS_HOST="plane-redis" REDIS_HOST="plane-redis"
REDIS_PORT="6379" REDIS_PORT="6379"
@ -50,7 +55,6 @@ NGINX_PORT=80
# SignUps # SignUps
ENABLE_SIGNUP="1" ENABLE_SIGNUP="1"
# Enable Email/Password Signup # Enable Email/Password Signup
ENABLE_EMAIL_PASSWORD="1" ENABLE_EMAIL_PASSWORD="1"

View File

@ -30,6 +30,11 @@ class CycleSerializer(BaseSerializer):
model = Cycle model = Cycle
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"id",
"created_at",
"updated_at",
"created_by",
"updated_by",
"workspace", "workspace",
"project", "project",
"owned_by", "owned_by",

View File

@ -1,3 +1,6 @@
from lxml import html
# Django imports # Django imports
from django.utils import timezone from django.utils import timezone
@ -21,7 +24,8 @@ from plane.db.models import (
from .base import BaseSerializer from .base import BaseSerializer
from .cycle import CycleSerializer, CycleLiteSerializer from .cycle import CycleSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleLiteSerializer from .module import ModuleSerializer, ModuleLiteSerializer
from .user import UserLiteSerializer
from .state import StateLiteSerializer
class IssueSerializer(BaseSerializer): class IssueSerializer(BaseSerializer):
assignees = serializers.ListField( assignees = serializers.ListField(
@ -42,7 +46,6 @@ class IssueSerializer(BaseSerializer):
class Meta: class Meta:
model = Issue model = Issue
fields = "__all__"
read_only_fields = [ read_only_fields = [
"id", "id",
"workspace", "workspace",
@ -52,6 +55,10 @@ class IssueSerializer(BaseSerializer):
"created_at", "created_at",
"updated_at", "updated_at",
] ]
exclude = [
"description",
"description_stripped",
]
def validate(self, data): def validate(self, data):
if ( if (
@ -61,6 +68,15 @@ class IssueSerializer(BaseSerializer):
): ):
raise serializers.ValidationError("Start date cannot exceed target date") 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 # Validate assignees are from project
if data.get("assignees", []): if data.get("assignees", []):
data["assignees"] = ProjectMember.objects.filter( data["assignees"] = ProjectMember.objects.filter(
@ -291,7 +307,6 @@ class IssueCommentSerializer(BaseSerializer):
class Meta: class Meta:
model = IssueComment model = IssueComment
fields = "__all__"
read_only_fields = [ read_only_fields = [
"id", "id",
"workspace", "workspace",
@ -302,6 +317,21 @@ class IssueCommentSerializer(BaseSerializer):
"created_at", "created_at",
"updated_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): class IssueActivitySerializer(BaseSerializer):
@ -331,12 +361,23 @@ class ModuleIssueSerializer(BaseSerializer):
] ]
class IssueExpandSerializer(BaseSerializer): class LabelLiteSerializer(BaseSerializer):
# Serialize the related cycle. It's a OneToOne relation.
cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True)
# 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) 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: class Meta:
model = Issue model = Issue

View File

@ -21,6 +21,7 @@ class ProjectSerializer(BaseSerializer):
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"id", "id",
'emoji',
"workspace", "workspace",
"created_at", "created_at",
"updated_at", "updated_at",

View File

@ -16,6 +16,11 @@ class StateSerializer(BaseSerializer):
model = State model = State
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"id",
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace", "workspace",
"project", "project",
] ]

View File

@ -11,10 +11,6 @@ class UserLiteSerializer(BaseSerializer):
"first_name", "first_name",
"last_name", "last_name",
"avatar", "avatar",
"is_bot",
"display_name", "display_name",
] ]
read_only_fields = [ read_only_fields = fields
"id",
"is_bot",
]

View File

@ -64,7 +64,7 @@ class StateAPIEndpoint(BaseAPIView):
) )
if state.default: 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 # Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=state_id).exists() issue_exist = Issue.issue_objects.filter(state=state_id).exists()

View File

@ -169,8 +169,8 @@ class ChangePasswordSerializer(serializers.Serializer):
Serializer for password change endpoint. Serializer for password change endpoint.
""" """
old_password = serializers.CharField(required=True) old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True) new_password = serializers.CharField(required=True, min_length=8)
confirm_password = serializers.CharField(required=True) confirm_password = serializers.CharField(required=True, min_length=8)
def validate(self, data): def validate(self, data):
if data.get("old_password") == data.get("new_password"): if data.get("old_password") == data.get("new_password"):
@ -187,9 +187,7 @@ class ChangePasswordSerializer(serializers.Serializer):
class ResetPasswordSerializer(serializers.Serializer): class ResetPasswordSerializer(serializers.Serializer):
model = User
""" """
Serializer for password change endpoint. Serializer for password change endpoint.
""" """
new_password = serializers.CharField(required=True) new_password = serializers.CharField(required=True, min_length=8)

View File

@ -105,17 +105,21 @@ class ForgotPasswordEndpoint(BaseAPIView):
def post(self, request): def post(self, request):
email = request.data.get("email") email = request.data.get("email")
if User.objects.filter(email=email).exists(): try:
user = User.objects.get(email=email) validate_email(email)
uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) except ValidationError:
token = PasswordResetTokenGenerator().make_token(user) 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") current_site = request.META.get("HTTP_ORIGIN")
# send the forgot password email
forgot_password.delay( forgot_password.delay(
user.first_name, user.email, uidb64, token, current_site user.first_name, user.email, uidb64, token, current_site
) )
return Response( return Response(
{"message": "Check your email to reset your password"}, {"message": "Check your email to reset your password"},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
@ -130,14 +134,18 @@ class ResetPasswordEndpoint(BaseAPIView):
def post(self, request, uidb64, token): def post(self, request, uidb64, token):
try: try:
# Decode the id from the uidb64
id = smart_str(urlsafe_base64_decode(uidb64)) id = smart_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(id=id) user = User.objects.get(id=id)
# check if the token is valid for the user
if not PasswordResetTokenGenerator().check_token(user, token): if not PasswordResetTokenGenerator().check_token(user, token):
return Response( return Response(
{"error": "Token is invalid"}, {"error": "Token is invalid"},
status=status.HTTP_401_UNAUTHORIZED, status=status.HTTP_401_UNAUTHORIZED,
) )
# Reset the password
serializer = ResetPasswordSerializer(data=request.data) serializer = ResetPasswordSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
# set_password also hashes the password that the user will get # set_password also hashes the password that the user will get
@ -145,9 +153,9 @@ class ResetPasswordEndpoint(BaseAPIView):
user.is_password_autoset = False user.is_password_autoset = False
user.save() user.save()
# Log the user in
# Generate access token for the user # Generate access token for the user
access_token, refresh_token = get_tokens_for_user(user) access_token, refresh_token = get_tokens_for_user(user)
data = { data = {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
@ -166,7 +174,6 @@ class ResetPasswordEndpoint(BaseAPIView):
class ChangePasswordEndpoint(BaseAPIView): class ChangePasswordEndpoint(BaseAPIView):
def post(self, request): def post(self, request):
serializer = ChangePasswordSerializer(data=request.data) serializer = ChangePasswordSerializer(data=request.data)
user = User.objects.get(pk=request.user.id) user = User.objects.get(pk=request.user.id)
if serializer.is_valid(): if serializer.is_valid():
if not user.check_password(serializer.data.get("old_password")): if not user.check_password(serializer.data.get("old_password")):
@ -218,16 +225,15 @@ class EmailCheckEndpoint(BaseAPIView):
] ]
def post(self, request): def post(self, request):
# get the email
# Check the instance registration # Check the instance registration
instance = Instance.objects.first() instance = Instance.objects.first()
if instance is None: if instance is None or not instance.is_setup_done:
return Response( return Response(
{"error": "Instance is not configured"}, {"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Get the configurations
instance_configuration = InstanceConfiguration.objects.values("key", "value") instance_configuration = InstanceConfiguration.objects.values("key", "value")
email = request.data.get("email", False) email = request.data.get("email", False)
@ -267,7 +273,7 @@ class EmailCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Create the user with default values
user = User.objects.create( user = User.objects.create(
email=email, email=email,
username=uuid.uuid4().hex, username=uuid.uuid4().hex,
@ -325,7 +331,7 @@ class EmailCheckEndpoint(BaseAPIView):
first_time=True, first_time=True,
) )
# Automatically send the email # 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 # Existing user
else: else:
if type == "magic_code": if type == "magic_code":

View File

@ -10,14 +10,12 @@ from django.utils import timezone
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import validate_email from django.core.validators import validate_email
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import make_password
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework import status from rest_framework import status
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from sentry_sdk import capture_message from sentry_sdk import capture_message
# Module imports # Module imports
@ -33,7 +31,6 @@ from plane.settings.redis import redis_instance
from plane.license.models import InstanceConfiguration, Instance from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_configuration_value from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.event_tracking_task import auth_events 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 from plane.bgtasks.user_count_task import update_user_instance_user_count
@ -49,6 +46,14 @@ class SignUpEndpoint(BaseAPIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
def post(self, request): 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") instance_configuration = InstanceConfiguration.objects.values("key", "value")
email = request.data.get("email", False) email = request.data.get("email", False)
@ -71,6 +76,7 @@ class SignUpEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, 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 ( if (
get_configuration_value( get_configuration_value(
instance_configuration, instance_configuration,
@ -124,6 +130,14 @@ class SignInEndpoint(BaseAPIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
def post(self, request): 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) email = request.data.get("email", False)
password = request.data.get("password", False) password = request.data.get("password", False)
@ -144,14 +158,6 @@ class SignInEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, 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 # Get the user
user = User.objects.filter(email=email).first() user = User.objects.filter(email=email).first()
@ -288,6 +294,7 @@ class MagicSignInEndpoint(BaseAPIView):
] ]
def post(self, request): def post(self, request):
# Check if the instance configuration is done
instance = Instance.objects.first() instance = Instance.objects.first()
if instance is None or not instance.is_setup_done: if instance is None or not instance.is_setup_done:
return Response( return Response(

View File

@ -303,14 +303,6 @@ class OauthEndpoint(BaseAPIView):
instance_configuration = InstanceConfiguration.objects.values( instance_configuration = InstanceConfiguration.objects.values(
"key", "value" "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 ( if (
get_configuration_value( get_configuration_value(
instance_configuration, instance_configuration,

View File

@ -77,7 +77,7 @@ class StateViewSet(BaseViewSet):
) )
if state.default: 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 # Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=pk).exists() issue_exist = Issue.issue_objects.filter(state=pk).exists()

View File

@ -1,7 +1,8 @@
# Python imports # Python imports
import csv import csv
import io import io
import os import requests
import json
# Django imports # Django imports
from django.core.mail import EmailMultiAlternatives, get_connection 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.db.models import Issue
from plane.utils.analytics_plot import build_graph_plot from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.license.models import InstanceConfiguration from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_configuration_value from plane.license.utils.instance_value import get_email_configuration
row_mapping = { row_mapping = {
"state__name": "State", "state__name": "State",
@ -43,7 +44,7 @@ CYCLE_ID = "issue_cycle__cycle_id"
MODULE_ID = "issue_module__module_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.""" """Helper function to send export email."""
subject = "Your Export is ready" subject = "Your Export is ready"
html_content = render_to_string("emails/exports/analytics.html", {}) 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( instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_" key__startswith="EMAIL_"
).values("key", "value") ).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( connection = get_connection(
host=get_configuration_value( host=EMAIL_HOST,
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") port=int(EMAIL_PORT),
), username=EMAIL_HOST_USER,
port=int( password=EMAIL_HOST_PASSWORD,
get_configuration_value( use_tls=bool(EMAIL_USE_TLS),
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"),
)
),
) )
msg = EmailMultiAlternatives( msg = EmailMultiAlternatives(
subject=subject, subject=subject,
body=text_content, body=text_content,
from_email=get_configuration_value( from_email=EMAIL_FROM,
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email], to=[email],
connection=connection, connection=connection,
) )
msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue()) msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue())
msg.send(fail_silently=False) msg.send(fail_silently=False)
return
def get_assignee_details(slug, filters): def get_assignee_details(slug, filters):
@ -463,8 +475,11 @@ def analytic_export_task(email, data, slug):
) )
csv_buffer = generate_csv_from_rows(rows) 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: except Exception as e:
print(e)
if settings.DEBUG: if settings.DEBUG:
print(e) print(e)
capture_exception(e) capture_exception(e)
return

View File

@ -40,13 +40,10 @@ def forgot_password(first_name, email, uidb64, token, current_site):
) = get_email_configuration(instance_configuration=instance_configuration) ) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured # 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 # Check the instance registration
instance = Instance.objects.first() instance = Instance.objects.first()
# send the emails through control center
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False)
# headers # headers
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -61,7 +58,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
} }
_ = requests.post( _ = 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, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )

View File

@ -21,7 +21,6 @@ from plane.license.utils.instance_value import get_email_configuration
@shared_task @shared_task
def magic_link(email, key, token, current_site): def magic_link(email, key, token, current_site):
try: try:
instance_configuration = InstanceConfiguration.objects.filter( instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_" key__startswith="EMAIL_"
).values("key", "value") ).values("key", "value")
@ -36,13 +35,10 @@ def magic_link(email, key, token, current_site):
) = get_email_configuration(instance_configuration=instance_configuration) ) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured # 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 # Check the instance registration
instance = Instance.objects.first() 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", "Content-Type": "application/json",
"x-instance-id": instance.instance_id, "x-instance-id": instance.instance_id,
@ -55,7 +51,7 @@ def magic_link(email, key, token, current_site):
} }
_ = requests.post( _ = 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, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )

View File

@ -32,13 +32,9 @@ def update_user_instance_user_count():
"x-api-key": instance.api_key, "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 # Update the license engine
_ = requests.post( _ = requests.post(
f"{license_engine_base_url}/api/instances/", f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )

View File

@ -109,7 +109,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
if webhook.secret_key: if webhook.secret_key:
hmac_signature = hmac.new( hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"), webhook.secret_key.encode("utf-8"),
json.dumps(payload, sort_keys=True).encode("utf-8"), json.dumps(payload).encode("utf-8"),
hashlib.sha256, hashlib.sha256,
) )
signature = hmac_signature.hexdigest() signature = hmac_signature.hexdigest()

View File

@ -51,15 +51,10 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
) = get_email_configuration(instance_configuration=instance_configuration) ) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured # 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 # Check the instance registration
instance = Instance.objects.first() 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 = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-instance-id": instance.instance_id, "x-instance-id": instance.instance_id,
@ -73,7 +68,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
"email": email, "email": email,
} }
_ = requests.post( _ = 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, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )

View File

@ -11,6 +11,7 @@ from django.utils import timezone
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.core.validators import validate_email from django.core.validators import validate_email
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.conf import settings
# Third party imports # Third party imports
from rest_framework import status 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.license.utils.encryption import encrypt_data
from plane.settings.redis import redis_instance from plane.settings.redis import redis_instance
from plane.bgtasks.magic_link_code_task import magic_link from plane.bgtasks.magic_link_code_task import magic_link
from plane.license.utils.instance_value import get_configuration_value
class InstanceEndpoint(BaseAPIView): class InstanceEndpoint(BaseAPIView):
@ -57,25 +57,17 @@ class InstanceEndpoint(BaseAPIView):
# Load JSON content from the file # Load JSON content from the file
data = json.load(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"} headers = {"Content-Type": "application/json"}
payload = { payload = {
"instance_key": os.environ.get("INSTANCE_KEY"), "instance_key":settings.INSTANCE_KEY,
"version": data.get("version", 0.1), "version": data.get("version", 0.1),
"machine_signature": os.environ.get("MACHINE_SIGNATURE"), "machine_signature": os.environ.get("MACHINE_SIGNATURE"),
"user_count": User.objects.filter(is_bot=False).count(), "user_count": User.objects.filter(is_bot=False).count(),
} }
response = requests.post( response = requests.post(
f"{license_engine_base_url}/api/instances/", f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )
@ -130,6 +122,24 @@ class InstanceEndpoint(BaseAPIView):
serializer = InstanceSerializer(instance, data=request.data, partial=True) serializer = InstanceSerializer(instance, data=request.data, partial=True)
if serializer.is_valid(): if serializer.is_valid():
serializer.save() 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.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -251,7 +261,6 @@ class AdminMagicSignInGenerateEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
if not email: if not email:
return Response( return Response(
{"error": "Please provide a valid email address"}, {"error": "Please provide a valid email address"},
@ -409,13 +418,6 @@ class AdminSetUserPasswordEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, 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 # Save the user in control center
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -423,14 +425,14 @@ class AdminSetUserPasswordEndpoint(BaseAPIView):
"x-api-key": instance.api_key, "x-api-key": instance.api_key,
} }
_ = requests.patch( _ = requests.patch(
f"{license_engine_base_url}/api/instances/", f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers, headers=headers,
data=json.dumps({"is_setup_done": True}), data=json.dumps({"is_setup_done": True}),
) )
# Also register the user as admin # Also register the user as admin
_ = requests.post( _ = requests.post(
f"{license_engine_base_url}/api/instances/users/register/", f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/register/",
headers=headers, headers=headers,
data=json.dumps( data=json.dumps(
{ {
@ -472,23 +474,19 @@ class SignUpScreenVisitedEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) if not instance.is_signup_screen_visited:
instance.is_signup_screen_visited = True
if not license_engine_base_url: instance.save()
return Response( # set the headers
{"error": "License engine base url is required"},
status=status.HTTP_400_BAD_REQUEST,
)
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-instance-id": instance.instance_id, "x-instance-id": instance.instance_id,
"x-api-key": instance.api_key, "x-api-key": instance.api_key,
} }
# create the payload
payload = {"is_signup_screen_visited": True} payload = {"is_signup_screen_visited": True}
response = requests.patch( _ = requests.patch(
f"{license_engine_base_url}/api/instances/", f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )

View File

@ -6,6 +6,7 @@ import requests
# Django imports # Django imports
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone from django.utils import timezone
from django.conf import settings
# Module imports # Module imports
from plane.license.models import Instance from plane.license.models import Instance
@ -30,31 +31,22 @@ class Command(BaseCommand):
data = json.load(file) data = json.load(file)
machine_signature = options.get("machine_signature", False) 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: if not machine_signature:
raise CommandError("Machine signature is required") 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"} headers = {"Content-Type": "application/json"}
payload = { payload = {
"instance_key": instance_key, "instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1), "version": data.get("version", 0.1),
"machine_signature": machine_signature, "machine_signature": machine_signature,
"user_count": User.objects.filter(is_bot=False).count(), "user_count": User.objects.filter(is_bot=False).count(),
} }
response = requests.post( response = requests.post(
f"{license_engine_base_url}/api/instances/", f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )

View File

@ -290,7 +290,7 @@ CELERY_IMPORTS = (
# Sentry Settings # Sentry Settings
# Enable 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( sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN", ""), dsn=os.environ.get("SENTRY_DSN", ""),
integrations=[ integrations=[
@ -325,3 +325,9 @@ USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
# Posthog settings # Posthog settings
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False) POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False) 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")

View File

@ -63,7 +63,7 @@ def date_filter(filter, date_term, queries):
duration=int(digit), duration=int(digit),
subsequent=date_query[1], subsequent=date_query[1],
term=term, term=term,
date_filter="created_at__date", date_filter=date_term,
offset=date_query[2], offset=date_query[2],
) )
else: else:

View File

@ -38,3 +38,4 @@ beautifulsoup4==4.12.2
dj-database-url==2.1.0 dj-database-url==2.1.0
posthog==3.0.2 posthog==3.0.2
cryptography==41.0.5 cryptography==41.0.5
lxml==4.9.3

View File

@ -149,20 +149,20 @@
padding-top: 10px !important; padding-top: 10px !important;
} }
.r13-o { .r13-o {
border-bottom-color: #efefef !important; border-bottom-color: #d9e4ff !important;
border-bottom-width: 1px !important; border-bottom-width: 1px !important;
border-left-color: #efefef !important; border-left-color: #d9e4ff !important;
border-left-width: 1px !important; border-left-width: 1px !important;
border-right-color: #efefef !important; border-right-color: #d9e4ff !important;
border-right-width: 1px !important; border-right-width: 1px !important;
border-style: solid !important; border-style: solid !important;
border-top-color: #efefef !important; border-top-color: #d9e4ff !important;
border-top-width: 1px !important; border-top-width: 1px !important;
margin: 0 auto 0 0 !important; margin: 0 auto 0 0 !important;
width: 100% !important; width: 100% !important;
} }
.r14-i { .r14-i {
background-color: #e3e6f1 !important; background-color: #ecf1ff !important;
padding-bottom: 10px !important; padding-bottom: 10px !important;
padding-left: 10px !important; padding-left: 10px !important;
padding-right: 10px !important; padding-right: 10px !important;
@ -225,16 +225,11 @@
padding-top: 5px !important; padding-top: 5px !important;
} }
.r24-o { .r24-o {
border-style: solid !important;
margin-right: 8px !important;
width: 32px !important;
}
.r25-o {
border-style: solid !important; border-style: solid !important;
margin-right: 0px !important; margin-right: 0px !important;
width: 32px !important; width: 32px !important;
} }
.r26-i { .r25-i {
padding-bottom: 0px !important; padding-bottom: 0px !important;
padding-top: 5px !important; padding-top: 5px !important;
text-align: center !important; text-align: center !important;
@ -664,17 +659,17 @@
width="100%" width="100%"
class="r13-o" class="r13-o"
style=" style="
background-color: #e3e6f1; background-color: #ecf1ff;
border-bottom-color: #efefef; border-bottom-color: #d9e4ff;
border-bottom-width: 1px; border-bottom-width: 1px;
border-collapse: separate; border-collapse: separate;
border-left-color: #efefef; border-left-color: #d9e4ff;
border-left-width: 1px; border-left-width: 1px;
border-radius: 5px; border-radius: 5px;
border-right-color: #efefef; border-right-color: #d9e4ff;
border-right-width: 1px; border-right-width: 1px;
border-style: solid; border-style: solid;
border-top-color: #efefef; border-top-color: #d9e4ff;
border-top-width: 1px; border-top-width: 1px;
table-layout: fixed; table-layout: fixed;
width: 100%; width: 100%;
@ -690,7 +685,7 @@
font-family: georgia, serif; font-family: georgia, serif;
font-size: 16px; font-size: 16px;
word-break: break-word; word-break: break-word;
background-color: #e3e6f1; background-color: #ecf1ff;
border-radius: 4px; border-radius: 4px;
line-height: 3; line-height: 3;
padding-bottom: 10px; padding-bottom: 10px;
@ -714,10 +709,10 @@
<p style="margin: 0"> <p style="margin: 0">
<span <span
style=" style="
color: #716c6c; color: #5f5e5e;
font-family: Arial, font-family: Arial,
helvetica, sans-serif; helvetica, sans-serif;
font-size: 15px; font-size: 14px;
" "
>Please copy and paste this >Please copy and paste this
on the screen where you on the screen where you
@ -1251,13 +1246,14 @@
role="presentation" role="presentation"
width="100%" width="100%"
class="r22-o" class="r22-o"
class="r24-o"
style=" style="
table-layout: fixed;
width: 100%;
"
> >
<tr> <tr>
<td <td
class="r23-i" class="r23-i"
class="r17-i"
style=" style="
font-size: 0px; font-size: 0px;
line-height: 0px; line-height: 0px;
@ -1309,7 +1305,7 @@
border="0" border="0"
role="presentation" role="presentation"
width="100%" width="100%"
class="r25-o" class="r24-o"
style=" style="
table-layout: fixed; table-layout: fixed;
width: 100%; width: 100%;
@ -1441,7 +1437,7 @@
<td <td
align="center" align="center"
valign="top" valign="top"
class="r26-i nl2go-default-textstyle" class="r25-i nl2go-default-textstyle"
style=" style="
color: #3b3f44; color: #3b3f44;
font-family: georgia, serif; font-family: georgia, serif;

View File

@ -1,9 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
Dear {{username}},<br/>
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.</br>
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 <a href = "mailto: engineering@plane.com">engineering@plane.so</a>. We're here to help!</br>
Thank you for using Plane. We hope this export will aid you in effectively managing your projects.</br>
Regards,
Team Plane
</html>

View File

@ -71,7 +71,6 @@ services:
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0} - ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
- WEB_URL=$SERVICE_FQDN_PLANE_8082 - WEB_URL=$SERVICE_FQDN_PLANE_8082
- LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"}
depends_on: depends_on:
- plane-db - plane-db
- plane-redis - plane-redis
@ -117,7 +116,6 @@ services:
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
- LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"}
depends_on: depends_on:
- api - api
- plane-db - plane-db
@ -164,7 +162,6 @@ services:
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
- LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"}
depends_on: depends_on:
- api - api
- plane-db - plane-db

View File

@ -5,15 +5,16 @@ x-app-env : &app-env
- NGINX_PORT=${NGINX_PORT:-80} - NGINX_PORT=${NGINX_PORT:-80}
- WEB_URL=${WEB_URL:-http://localhost} - WEB_URL=${WEB_URL:-http://localhost}
- DEBUG=${DEBUG:-0} - DEBUG=${DEBUG:-0}
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.production} # deprecated
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} - 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_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:-""} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1} # deprecated - DOCKERIZED=${DOCKERIZED:-1} # deprecated
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""} - 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=${GUNICORN_WORKERS:-2} - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
#DB SETTINGS #DB SETTINGS
@ -28,12 +29,12 @@ x-app-env : &app-env
- REDIS_HOST=${REDIS_HOST:-plane-redis} - REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}: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=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""} - EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""} - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587} - EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-"Team Plane &lt;team@mailer.plane.so&gt;"} - EMAIL_FROM=${EMAIL_FROM:-"Team Plane <team@mailer.plane.so>"}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1} - EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} - 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_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"} - OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"}
- GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"} - 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_SIGNUP=${ENABLE_SIGNUP:-1}
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1} - ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0} - ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
# Application secret
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
# DATA STORE SETTINGS # DATA STORE SETTINGS
- USE_MINIO=${USE_MINIO:-1} - USE_MINIO=${USE_MINIO:-1}

View File

@ -10,10 +10,12 @@ DEBUG=0
NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_ENABLE_OAUTH=0
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
SENTRY_DSN="" SENTRY_DSN=""
SENTRY_ENVIRONMENT="production"
GOOGLE_CLIENT_ID=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET="" GITHUB_CLIENT_SECRET=""
DOCKERIZED=1 # deprecated DOCKERIZED=1 # deprecated
CORS_ALLOWED_ORIGINS="http://localhost" CORS_ALLOWED_ORIGINS="http://localhost"
SENTRY_ENVIRONMENT="production"
#DB SETTINGS #DB SETTINGS
PGHOST=plane-db PGHOST=plane-db
@ -34,7 +36,7 @@ EMAIL_HOST=""
EMAIL_HOST_USER="" EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD="" EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587 EMAIL_PORT=587
EMAIL_FROM="Team Plane &lt;team@mailer.plane.so&gt;" EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS=1 EMAIL_USE_TLS=1
EMAIL_USE_SSL=0 EMAIL_USE_SSL=0
@ -63,9 +65,3 @@ FILE_SIZE_LIMIT=5242880
# Gunicorn Workers # Gunicorn Workers
GUNICORN_WORKERS=2 GUNICORN_WORKERS=2
# Admin Email
ADMIN_EMAIL=""
# License Engine url
LICENSE_ENGINE_BASE_URL=""

View File

@ -11,7 +11,7 @@ import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer"; import useTimer from "hooks/use-timer";
// ui // ui
import { Input, PrimaryButton } from "components/ui"; import { Button, Input } from "@plane/ui";
// types // types
type EmailCodeFormValues = { type EmailCodeFormValues = {
@ -133,7 +133,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
id="email" id="email"
type="email" type="email"
placeholder="Enter your email address..." placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]" className="border-custom-border-300 h-[46px] w-full"
{...register("email", { {...register("email", {
required: "Email address is required", required: "Email address is required",
validate: (value) => validate: (value) =>
@ -154,7 +154,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
required: "Code is required", required: "Code is required",
})} })}
placeholder="Enter code..." placeholder="Enter code..."
className="border-custom-border-300 h-[46px]" className="border-custom-border-300 h-[46px] w-full"
/> />
{errors.token && <div className="text-sm text-red-500">{errors.token.message}</div>} {errors.token && <div className="text-sm text-red-500">{errors.token.message}</div>}
<button <button
@ -185,20 +185,22 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
</> </>
)} )}
{codeSent ? ( {codeSent ? (
<PrimaryButton <Button
variant="primary"
type="submit" type="submit"
className="w-full text-center h-[46px]" className="w-full"
size="md" size="xl"
onClick={handleSubmit(handleSignin)} onClick={handleSubmit(handleSignin)}
disabled={!isValid && isDirty} disabled={!isValid && isDirty}
loading={isLoading} loading={isLoading}
> >
{isLoading ? "Signing in..." : "Sign in"} {isLoading ? "Signing in..." : "Sign in"}
</PrimaryButton> </Button>
) : ( ) : (
<PrimaryButton <Button
className="w-full text-center h-[46px]" variant="primary"
size="md" className="w-full"
size="xl"
onClick={() => { onClick={() => {
handleSubmit(onSubmit)().then(() => { handleSubmit(onSubmit)().then(() => {
setResendCodeTimer(30); setResendCodeTimer(30);
@ -208,7 +210,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
loading={isSubmitting} loading={isSubmitting}
> >
{isSubmitting ? "Sending code..." : "Send sign in code"} {isSubmitting ? "Sending code..." : "Send sign in code"}
</PrimaryButton> </Button>
)} )}
</form> </form>
</> </>

View File

@ -5,7 +5,8 @@ import { useForm } from "react-hook-form";
// components // components
import { EmailResetPasswordForm } from "./email-reset-password-form"; import { EmailResetPasswordForm } from "./email-reset-password-form";
// ui // ui
import { Input, PrimaryButton } from "components/ui"; import { Button, Input } from "@plane/ui";
// types // types
type EmailPasswordFormValues = { type EmailPasswordFormValues = {
email: string; email: string;
@ -58,7 +59,7 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
) || "Email address is not valid", ) || "Email address is not valid",
})} })}
placeholder="Enter your email address..." placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]" className="border-custom-border-300 h-[46px] w-full"
/> />
{errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>} {errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>}
</div> </div>
@ -70,7 +71,7 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
required: "Password is required", required: "Password is required",
})} })}
placeholder="Enter your password..." placeholder="Enter your password..."
className="border-custom-border-300 h-[46px]" className="border-custom-border-300 h-[46px] w-full"
/> />
{errors.password && <div className="text-sm text-red-500">{errors.password.message}</div>} {errors.password && <div className="text-sm text-red-500">{errors.password.message}</div>}
</div> </div>
@ -92,14 +93,16 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
)} )}
</div> </div>
<div> <div>
<PrimaryButton <Button
variant="primary"
type="submit" type="submit"
className="w-full text-center h-[46px]" size="xl"
className="w-full"
disabled={!isValid && isDirty} disabled={!isValid && isDirty}
loading={isSubmitting} loading={isSubmitting}
> >
{isSignUpPage ? (isSubmitting ? "Signing up..." : "Sign up") : isSubmitting ? "Signing in..." : "Sign in"} {isSignUpPage ? (isSubmitting ? "Signing up..." : "Sign up") : isSubmitting ? "Signing in..." : "Sign in"}
</PrimaryButton> </Button>
{!isSignUpPage && ( {!isSignUpPage && (
<Link href="/sign-up"> <Link href="/sign-up">
<span className="block text-custom-text-200 hover:text-custom-primary-100 text-xs mt-4"> <span className="block text-custom-text-200 hover:text-custom-primary-100 text-xs mt-4">

View File

@ -1,8 +1,7 @@
import React from "react"; import React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// ui // ui
import { Input } from "components/ui"; import { Button, Input } from "@plane/ui";
import { Button } from "@plane/ui";
// types // types
type Props = { type Props = {
setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>; setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>;
@ -66,15 +65,15 @@ export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword
) || "Email address is not valid", ) || "Email address is not valid",
})} })}
placeholder="Enter registered email address.." placeholder="Enter registered email address.."
className="h-[46px] border-custom-border-300" className="h-[46px] border-custom-border-300 w-full"
/> />
{errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>} {errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>}
</div> </div>
<div className="mt-5 flex flex-col-reverse items-center gap-2 sm:flex-row"> <div className="mt-5 flex flex-col-reverse items-center gap-2 sm:flex-row">
<Button variant="neutral-primary" className="w-full" onClick={() => setIsResettingPassword(false)}> <Button variant="neutral-primary" className="w-full" size="xl" onClick={() => setIsResettingPassword(false)}>
Go Back Go Back
</Button> </Button>
<Button variant="primary" className="w-full" type="submit" loading={isSubmitting}> <Button variant="primary" className="w-full" size="xl" type="submit" loading={isSubmitting}>
{isSubmitting ? "Sending link..." : "Send reset link"} {isSubmitting ? "Sending link..." : "Send reset link"}
</Button> </Button>
</div> </div>

View File

@ -13,7 +13,7 @@ import useToast from "hooks/use-toast";
// services // services
import UserService from "services/user.service"; import UserService from "services/user.service";
// ui // ui
import { Input, PrimaryButton } from "components/ui"; import { Button, Input } from "@plane/ui";
const defaultValues = { const defaultValues = {
first_name: "", first_name: "",
@ -173,9 +173,9 @@ export const OnBoardingForm: React.FC<Props> = observer(({ user }) => {
</div> </div>
</div> </div>
<PrimaryButton type="submit" size="md" disabled={!isValid} loading={isSubmitting}> <Button variant="primary" type="submit" className="w-full" size="xl" disabled={!isValid} loading={isSubmitting}>
{isSubmitting ? "Updating..." : "Continue"} {isSubmitting ? "Updating..." : "Continue"}
</PrimaryButton> </Button>
</form> </form>
); );
}); });

View File

@ -13,7 +13,7 @@ import { IIssue } from "types/issue";
import { RootStore } from "store/root"; import { RootStore } from "store/root";
import { useRouter } from "next/router"; 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(); const { project: projectStore, issueDetails: issueDetailStore }: RootStore = useMobxStore();
// router // router

View File

@ -10,7 +10,7 @@ import { StateGroupIcon } from "@plane/ui";
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root"; import { RootStore } from "store/root";
export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { export const IssueKanBanHeader = observer(({ state }: { state: IIssueState }) => {
const store: RootStore = useMobxStore(); const store: RootStore = useMobxStore();
const stateGroup = issueGroupFilter(state.group); const stateGroup = issueGroupFilter(state.group);

View File

@ -3,8 +3,8 @@
// mobx react lite // mobx react lite
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// components // components
import { IssueListHeader } from "components/issues/board-views/kanban/header"; import { IssueKanBanHeader } from "components/issues/board-views/kanban/header";
import { IssueListBlock } from "components/issues/board-views/kanban/block"; import { IssueKanBanBlock } from "components/issues/board-views/kanban/block";
// ui // ui
import { Icon } from "components/ui"; import { Icon } from "components/ui";
// interfaces // interfaces
@ -23,14 +23,14 @@ export const IssueKanbanView = observer(() => {
store?.issue?.states.map((_state: IIssueState) => ( store?.issue?.states.map((_state: IIssueState) => (
<div key={_state.id} className="flex-shrink-0 relative w-[340px] h-full flex flex-col"> <div key={_state.id} className="flex-shrink-0 relative w-[340px] h-full flex flex-col">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<IssueListHeader state={_state} /> <IssueKanBanHeader state={_state} />
</div> </div>
<div className="w-full h-full overflow-hidden overflow-y-auto hide-vertical-scrollbar"> <div className="w-full h-full overflow-hidden overflow-y-auto hide-vertical-scrollbar">
{store.issue.getFilteredIssuesByState(_state.id) && {store.issue.getFilteredIssuesByState(_state.id) &&
store.issue.getFilteredIssuesByState(_state.id).length > 0 ? ( store.issue.getFilteredIssuesByState(_state.id).length > 0 ? (
<div className="space-y-3 pb-2 px-2"> <div className="space-y-3 pb-2 px-2">
{store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( {store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
<IssueListBlock key={_issue.id} issue={_issue} /> <IssueKanBanBlock key={_issue.id} issue={_issue} />
))} ))}
</div> </div>
) : ( ) : (

View File

@ -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 (
<div className="flex-shrink-0 min-h-[50px] h-auto py-1.5 border-b border-custom-border-200 relative flex items-center shadow-md bg-whiate select-none">
<div className="px-5 flex justify-start items-center flex-wrap gap-2 text-sm">
{/* state */}
{/* {store.issue.checkIfFilterExistsForKey("state") && <IssueStateFilter />} */}
{/* labels */}
{/* {store.issue.checkIfFilterExistsForKey("label") && <IssueLabelFilter />} */}
{/* priority */}
{/* {store.issue.checkIfFilterExistsForKey("priority") && <IssuePriorityFilter />} */}
{/* clear all filters */}
<div
className="flex items-center gap-2 border border-custom-border-200 px-2 py-1 cursor-pointer text-xs rounded-full"
onClick={clearAllFilters}
>
<div>Clear all filters</div>
<div className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-sm">
<span className="material-symbols-rounded text-[12px]">close</span>
</div>
</div>
</div>
</div>
);
});
export default IssueFilter;

View File

@ -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 (
<div
className="flex-shrink-0 relative flex items-center flex-wrap gap-1 px-2 py-0.5 rounded-full select-none"
style={{ color: label?.color, backgroundColor: `${label?.color}10` }}
>
<div
className="flex-shrink-0 w-1.5 h-1.5 flex justify-center items-center overflow-hidden rounded-full"
style={{ backgroundColor: `${label?.color}` }}
/>
<div className="font-medium whitespace-nowrap text-xs">{label?.name}</div>
<div
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-full"
onClick={removeLabelFromFilter}
>
<span className="material-symbols-rounded text-xs">close</span>
</div>
</div>
);
});

View File

@ -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 (
<>
<div className="flex items-center gap-2 border border-custom-border-300 px-2 py-1 rounded-full text-xs">
<div className="flex-shrink-0 text-custom-text-200">Labels</div>
<div className="relative flex flex-wrap items-center gap-1">
{/* {store?.issue?.labels &&
store?.issue?.labels.map(
(_label: IIssueLabel, _index: number) =>
store.issue.getUserSelectedFilter("label", _label.id) && (
<RenderIssueLabel key={_label.id} label={_label} />
)
)} */}
</div>
<div
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-sm"
onClick={clearLabelFilters}
>
<span className="material-symbols-rounded text-[12px]">close</span>
</div>
</div>
</>
);
});
export default IssueLabelFilter;

View File

@ -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 (
<div
className={`flex-shrink-0 relative flex items-center flex-wrap gap-1 px-2 py-0.5 text-xs rounded-full select-none ${
priority.className || ``
}`}
>
<div className="flex-shrink-0 flex justify-center items-center overflow-hidden rounded-full">
<span className="material-symbols-rounded text-xs">{priority?.icon}</span>
</div>
<div className="whitespace-nowrap">{priority?.title}</div>
<div
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-full"
onClick={removePriorityFromFilter}
>
<span className="material-symbols-rounded text-xs">close</span>
</div>
</div>
);
});

View File

@ -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 (
<>
<div className="flex items-center gap-2 border border-custom-border-300 px-2 py-1 rounded-full text-xs">
<div className="flex-shrink-0 text-custom-text-200">Priority</div>
<div className="relative flex flex-wrap items-center gap-1">
{/* {issuePriorityFilters.map(
(_priority: IIssuePriorityFilters, _index: number) =>
store.issue.getUserSelectedFilter("priority", _priority.key) && (
<RenderIssuePriority key={_priority.key} priority={_priority} />
)
)} */}
</div>
<div
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-sm"
onClick={() => {
clearPriorityFilters();
}}
>
<span className="material-symbols-rounded text-[12px]">close</span>
</div>
</div>
</>
);
});
export default IssuePriorityFilter;

View File

@ -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 (
<div className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 ${stateGroup.className || ``}`}>
<div className="flex h-3 w-3 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
{/* <stateGroup.icon /> */}
</div>
<div className="whitespace-nowrap text-xs font-medium">{state?.name}</div>
<div
className="flex h-3 w-3 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full"
onClick={removeStateFromFilter}
>
<span className="material-symbols-rounded text-xs">close</span>
</div>
</div>
);
});

View File

@ -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 (
<>
<div className="flex items-center gap-2 border border-custom-border-300 px-2 py-1 rounded-full text-xs">
<div className="flex-shrink-0 text-custom-text-200">State</div>
<div className="relative flex flex-wrap items-center gap-1">
{/* {store?.issue?.states &&
store?.issue?.states.map(
(_state: IIssueState, _index: number) =>
store.issue.getUserSelectedFilter("state", _state.id) && (
<RenderIssueState key={_state.id} state={_state} />
)
)} */}
</div>
<div
className="flex-shrink-0 w-3 h-3 cursor-pointer flex justify-center items-center overflow-hidden rounded-sm"
onClick={clearStateFilters}
>
<span className="material-symbols-rounded text-[12px]">close</span>
</div>
</div>
</>
);
});
export default IssueStateFilter;

View File

@ -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> = (props) => {
const { appliedFilters, handleRemoveAllFilters, handleRemoveFilter, labels, states } = props;
return (
<div className="flex items-stretch gap-2 flex-wrap bg-custom-background-100">
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof IIssueFilterOptions;
if (!value) return;
return (
<div
key={filterKey}
className="capitalize py-1 px-2 border border-custom-border-200 rounded-md flex items-center gap-2 flex-wrap"
>
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
<div className="flex items-center gap-1 flex-wrap">
{filterKey === "priority" && (
<AppliedPriorityFilters handleRemove={(val) => handleRemoveFilter("priority", val)} values={value} />
)}
{filterKey === "labels" && labels && (
<AppliedLabelsFilters
handleRemove={(val) => handleRemoveFilter("labels", val)}
labels={labels}
values={value}
/>
)}
{filterKey === "state" && states && (
<AppliedStateFilters
handleRemove={(val) => handleRemoveFilter("state", val)}
states={states}
values={value}
/>
)}
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<X size={12} strokeWidth={2} />
</button>
</div>
</div>
);
})}
<button
type="button"
onClick={handleRemoveAllFilters}
className="flex items-center gap-2 text-xs border border-custom-border-200 py-1 px-2 rounded-md text-custom-text-300 hover:text-custom-text-200"
>
Clear all
<X size={12} strokeWidth={2} />
</button>
</div>
);
};

View File

@ -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> = (props) => {
const { handleRemove, labels, values } = props;
return (
<>
{values.map((labelId) => {
const labelDetails = labels?.find((l) => l.id === labelId);
if (!labelDetails) return null;
return (
<div key={labelId} className="text-xs flex items-center gap-1 bg-custom-background-80 p-1 rounded">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: labelDetails.color,
}}
/>
<span className="normal-case">{labelDetails.name}</span>
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(labelId)}
>
<X size={10} strokeWidth={2} />
</button>
</div>
);
})}
</>
);
};

View File

@ -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> = (props) => {
const { handleRemove, values } = props;
return (
<>
{values &&
values.length > 0 &&
values.map((priority) => (
<div key={priority} className="text-xs flex items-center gap-1 bg-custom-background-80 p-1 rounded">
<PriorityIcon priority={priority as any} className={`h-3 w-3`} />
{priority}
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(priority)}
>
<X size={10} strokeWidth={2} />
</button>
</div>
))}
</>
);
};

View File

@ -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 (
<div className="p-5 py-3 border-b border-custom-border-200">
<AppliedFiltersList
appliedFilters={appliedFilters || {}}
handleRemoveFilter={handleRemoveFilter}
handleRemoveAllFilters={handleRemoveAllFilters}
labels={labels ?? []}
states={states ?? []}
/>
</div>
);
});

View File

@ -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> = (props) => {
const { handleRemove, states, values } = props;
return (
<>
{values.map((stateId) => {
const stateDetails = states?.find((s) => s.id === stateId);
if (!stateDetails) return null;
return (
<div key={stateId} className="text-xs flex items-center gap-1 bg-custom-background-80 p-1 rounded">
<StateGroupIcon color={stateDetails.color} stateGroup={stateDetails.group} height="12px" width="12px" />
{stateDetails.name}
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(stateId)}
>
<X size={10} strokeWidth={2} />
</button>
</div>
);
})}
</>
);
};

View File

@ -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> = (props) => {
const { children, title = "Dropdown", placement } = props;
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto",
});
return (
<Popover as="div">
{({ open }) => {
if (open) {
}
return (
<>
<Popover.Button as={React.Fragment}>
<Button
ref={setReferenceElement}
variant="neutral-primary"
size="sm"
appendIcon={
<ChevronUp className={`transition-all ${open ? "" : "rotate-180"}`} size={14} strokeWidth={2} />
}
>
<div className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}>
<span>{title}</span>
</div>
</Button>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel>
<div
className="z-10 bg-custom-background-100 border border-custom-border-200 shadow-custom-shadow-rg rounded overflow-hidden"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="w-[18.75rem] max-h-[37.5rem] flex flex-col overflow-hidden">{children}</div>
</div>
</Popover.Panel>
</Transition>
</>
);
}}
</Popover>
);
};

View File

@ -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) => (
<div className="flex items-center justify-between gap-2 bg-custom-background-100 sticky top-0">
<div className="text-custom-text-300 text-xs font-medium flex-grow truncate">{title}</div>
<button
type="button"
className="flex-shrink-0 w-5 h-5 grid place-items-center rounded hover:bg-custom-background-80"
onClick={handleIsPreviewEnabled}
>
{isPreviewEnabled ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
);

View File

@ -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> = (props) => {
const { icon, isChecked, multiple = true, onClick, title } = props;
return (
<button
type="button"
className="flex items-center gap-2 rounded hover:bg-custom-background-80 w-full p-1.5"
onClick={onClick}
>
<div
className={`flex-shrink-0 w-3 h-3 grid place-items-center bg-custom-background-90 border ${
isChecked ? "bg-custom-primary-100 border-custom-primary-100 text-white" : "border-custom-border-300"
} ${multiple ? "rounded-sm" : "rounded-full"}`}
>
{isChecked && <Check size={10} strokeWidth={3} />}
</div>
<div className="flex items-center gap-2 truncate">
{icon && <div className="flex-shrink-0 grid place-items-center w-5">{icon}</div>}
<div className="flex-grow truncate text-custom-text-200 text-xs">{title}</div>
</div>
</button>
);
};

View File

@ -0,0 +1,3 @@
export * from "./dropdown";
export * from "./filter-header";
export * from "./filter-option";

View File

@ -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";

View File

@ -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 }) => (
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: color }} />
);
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
labels: IIssueLabel[] | undefined;
searchQuery: string;
};
export const FilterLabels: React.FC<Props> = (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 (
<>
<FilterHeader
title={`Label${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions ? (
filteredOptions.length > 0 ? (
<>
{filteredOptions.slice(0, itemsToRender).map((label) => (
<FilterOption
key={label?.id}
isChecked={appliedFilters?.includes(label?.id) ? true : false}
onClick={() => handleUpdate(label?.id)}
icon={<LabelIcons color={label.color} />}
title={label.name}
/>
))}
{filteredOptions.length > 5 && (
<button
type="button"
className="text-custom-primary-100 text-xs font-medium ml-8"
onClick={handleViewToggle}
>
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs text-custom-text-400 italic">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
};

View File

@ -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<Props> = 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 (
<>
<FilterHeader
title={`Priority${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
filteredOptions.map((priority) => (
<FilterOption
key={priority.key}
isChecked={appliedFilters?.includes(priority.key) ? true : false}
onClick={() => handleUpdate(priority.key)}
icon={<PriorityIcon priority={priority.key} className="h-3.5 w-3.5" />}
title={priority.title}
/>
))
) : (
<p className="text-xs text-custom-text-400 italic">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@ -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 (
<div className="w-full h-full flex flex-col z-50">
<FiltersDropdown title="Filters" placement="bottom-end">
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFilters={handleFilters}
layoutDisplayFiltersOptions={activeBoard ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeBoard] : undefined}
states={states ?? undefined}
labels={labels ?? undefined}
/>
</FiltersDropdown>
</div>
);
});

View File

@ -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<Props> = observer((props) => {
const { filters, handleFilters, layoutDisplayFiltersOptions, labels, states } = props;
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
const isFilterEnabled = (filter: keyof IIssueFilterOptions) => layoutDisplayFiltersOptions?.filters.includes(filter);
return (
<div className="w-full h-full flex flex-col overflow-hidden">
<div className="p-2.5 pb-0 bg-custom-background-100">
<div className="bg-custom-background-90 border-[0.5px] border-custom-border-200 text-xs rounded flex items-center gap-1.5 px-1.5 py-1">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="bg-custom-background-90 placeholder:text-custom-text-400 w-full outline-none"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="w-full h-full divide-y divide-custom-border-200 px-2.5 overflow-y-auto">
{/* priority */}
{isFilterEnabled("priority") && (
<div className="py-2">
<FilterPriority
appliedFilters={filters.priority ?? null}
handleUpdate={(val) => handleFilters("priority", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* state */}
{isFilterEnabled("state") && (
<div className="py-2">
<FilterState
appliedFilters={filters.state ?? null}
handleUpdate={(val) => handleFilters("state", val)}
searchQuery={filtersSearchQuery}
states={states}
/>
</div>
)}
{/* labels */}
{isFilterEnabled("labels") && (
<div className="py-2">
<FilterLabels
appliedFilters={filters.labels ?? null}
handleUpdate={(val) => handleFilters("labels", val)}
labels={labels}
searchQuery={filtersSearchQuery}
/>
</div>
)}
</div>
</div>
);
});

View File

@ -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> = (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 (
<>
<FilterHeader
title={`State${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions ? (
filteredOptions.length > 0 ? (
<>
{filteredOptions.slice(0, itemsToRender).map((state) => (
<FilterOption
key={state.id}
isChecked={appliedFilters?.includes(state.id) ? true : false}
onClick={() => handleUpdate(state.id)}
icon={<StateGroupIcon stateGroup={state.group} color={state.color} />}
title={state.name}
/>
))}
{filteredOptions.length > 5 && (
<button
type="button"
className="text-custom-primary-100 text-xs font-medium ml-8"
onClick={handleViewToggle}
>
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs text-custom-text-400 italic">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
};

View File

@ -1,7 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// mobx // mobx
@ -10,12 +9,15 @@ import { observer } from "mobx-react-lite";
// import { NavbarSearch } from "./search"; // import { NavbarSearch } from "./search";
import { NavbarIssueBoardView } from "./issue-board-view"; import { NavbarIssueBoardView } from "./issue-board-view";
import { NavbarTheme } from "./theme"; import { NavbarTheme } from "./theme";
import { IssueFiltersDropdown } from "components/issues/filters";
// ui // ui
import { PrimaryButton } from "components/ui"; import { Avatar, Button } from "@plane/ui";
import { Briefcase } from "lucide-react";
// lib // lib
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// store // store
import { RootStore } from "store/root"; import { RootStore } from "store/root";
import { TIssueBoardKeys } from "types/issue";
const renderEmoji = (emoji: string | { name: string; color: string }) => { const renderEmoji = (emoji: string | { name: string; color: string }) => {
if (!emoji) return; if (!emoji) return;
@ -30,10 +32,21 @@ const renderEmoji = (emoji: string | { name: string; color: string }) => {
}; };
const IssueNavbar = observer(() => { const IssueNavbar = observer(() => {
const { project: projectStore, user: userStore }: RootStore = useMobxStore(); const {
project: projectStore,
user: userStore,
issuesFilter: { updateFilters },
}: RootStore = useMobxStore();
// router // router
const router = useRouter(); 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; const user = userStore?.currentUser;
@ -46,7 +59,7 @@ const IssueNavbar = observer(() => {
useEffect(() => { useEffect(() => {
if (workspace_slug && project_slug && projectStore?.deploySettings) { if (workspace_slug && project_slug && projectStore?.deploySettings) {
const viewsAcceptable: string[] = []; 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?.list) viewsAcceptable.push("list");
if (projectStore?.deploySettings?.views?.kanban) viewsAcceptable.push("kanban"); if (projectStore?.deploySettings?.views?.kanban) viewsAcceptable.push("kanban");
@ -56,41 +69,65 @@ const IssueNavbar = observer(() => {
if (board) { if (board) {
if (viewsAcceptable.includes(board.toString())) { if (viewsAcceptable.includes(board.toString())) {
currentBoard = board.toString(); currentBoard = board.toString() as TIssueBoardKeys;
} else { } else {
if (viewsAcceptable && viewsAcceptable.length > 0) { if (viewsAcceptable && viewsAcceptable.length > 0) {
currentBoard = viewsAcceptable[0]; currentBoard = viewsAcceptable[0] as TIssueBoardKeys;
} }
} }
} else { } else {
if (viewsAcceptable && viewsAcceptable.length > 0) { if (viewsAcceptable && viewsAcceptable.length > 0) {
currentBoard = viewsAcceptable[0]; currentBoard = viewsAcceptable[0] as TIssueBoardKeys;
} }
} }
if (currentBoard) { if (currentBoard) {
if (projectStore?.activeBoard === null || projectStore?.activeBoard !== 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); projectStore.setActiveBoard(currentBoard);
router.push({ router.push({
pathname: `/${workspace_slug}/${project_slug}`, pathname: `/${workspace_slug}/${project_slug}`,
query: { query: { ...params },
board: currentBoard,
},
}); });
} }
} }
} }
}, [board, workspace_slug, project_slug, router, projectStore, projectStore?.deploySettings]); }, [board, workspace_slug, project_slug, router, projectStore, projectStore?.deploySettings, updateFilters]);
return ( return (
<div className="relative flex w-full items-center gap-4 px-5"> <div className="relative flex w-full items-center gap-4 px-5">
{/* project detail */} {/* project detail */}
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
<div className="flex h-4 w-4 items-center justify-center"> <div className="flex h-4 w-4 items-center justify-center">
{projectStore?.project && projectStore?.project?.emoji ? ( {projectStore.project ? (
renderEmoji(projectStore?.project?.emoji) projectStore.project?.emoji ? (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
{renderEmoji(projectStore.project.emoji)}
</span>
) : projectStore.project?.icon_prop ? (
<div className="h-7 w-7 flex-shrink-0 grid place-items-center">
{renderEmoji(projectStore.project.icon_prop)}
</div>
) : ( ) : (
<Image src="/plane-logo.webp" alt="plane logo" className="h-[24px] w-[24px]" height="24" width="24" /> <span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{projectStore.project?.name.charAt(0)}
</span>
)
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
<Briefcase className="h-4 w-4" />
</span>
)} )}
</div> </div>
<div className="line-clamp-1 max-w-[300px] overflow-hidden text-lg font-medium"> <div className="line-clamp-1 max-w-[300px] overflow-hidden text-lg font-medium">
@ -106,6 +143,11 @@ const IssueNavbar = observer(() => {
<NavbarIssueBoardView /> <NavbarIssueBoardView />
</div> </div>
{/* issue filters */}
<div className="relative flex flex-shrink-0 items-center gap-1 transition-all delay-150 ease-in-out">
<IssueFiltersDropdown />
</div>
{/* theming */} {/* theming */}
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<NavbarTheme /> <NavbarTheme />
@ -113,26 +155,13 @@ const IssueNavbar = observer(() => {
{user ? ( {user ? (
<div className="flex items-center gap-2 rounded border border-custom-border-200 p-2"> <div className="flex items-center gap-2 rounded border border-custom-border-200 p-2">
{user.avatar && user.avatar !== "" ? ( <Avatar name={user?.display_name} src={user?.avatar} size={24} shape="square" className="!text-base" />
<div className="h-5 w-5 rounded-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={user.avatar} alt={user.display_name ?? ""} className="rounded-full" />
</div>
) : (
<div className="grid h-5 w-5 place-items-center rounded-full bg-custom-background-80 text-[10px] capitalize">
{(user.display_name ?? "A")[0]}
</div>
)}
<h6 className="text-xs font-medium">{user.display_name}</h6> <h6 className="text-xs font-medium">{user.display_name}</h6>
</div> </div>
) : ( ) : (
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Link href={`/login/?next_path=${router.asPath}`}> <Link href={`/login/?next_path=${router.asPath}`}>
<span> <Button variant="outline-primary">Sign in</Button>
<PrimaryButton className="flex-shrink-0" outline>
Sign in
</PrimaryButton>
</span>
</Link> </Link>
</div> </div>
)} )}

View File

@ -5,6 +5,7 @@ import { issueViews } from "constants/data";
// mobx // mobx
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root"; import { RootStore } from "store/root";
import { TIssueBoardKeys } from "types/issue";
export const NavbarIssueBoardView = observer(() => { export const NavbarIssueBoardView = observer(() => {
const { const {
@ -15,7 +16,7 @@ export const NavbarIssueBoardView = observer(() => {
const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string };
const handleCurrentBoardView = (boardView: string) => { const handleCurrentBoardView = (boardView: string) => {
setActiveBoard(boardView); setActiveBoard(boardView as TIssueBoardKeys);
router.push(`/${workspace_slug}/${project_slug}?board=${boardView}`); router.push(`/${workspace_slug}/${project_slug}?board=${boardView}`);
}; };

View File

@ -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 (
<Dropdown
button={
<>
<span>Filters</span>
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</>
}
items={[
{
display: "Priority",
children: PRIORITIES.map((priority) => ({
display: (
<span className="capitalize flex items-center gap-x-2">
<span className="material-symbols-rounded text-[14px]">
{priority === "urgent"
? "error"
: priority === "high"
? "signal_cellular_alt"
: priority === "medium"
? "signal_cellular_alt_2_bar"
: "signal_cellular_alt_1_bar"}
</span>
{priority}
</span>
),
onClick: () => handleOnSelect("priorities", priority),
isSelected: store.issue.filteredPriorities.includes(priority),
})),
},
{
display: "State",
children: (store.issue.states || []).map((state) => {
const stateGroup = issueGroupFilter(state.group);
return {
display: (
<span className="capitalize flex items-center gap-x-2">
{/* {stateGroup && <stateGroup.icon />} */}
{state.name}
</span>
),
onClick: () => handleOnSelect("states", state.id),
isSelected: store.issue.filteredStates.includes(state.id),
};
}),
},
{
display: "Labels",
children: [...(store.issue.labels || [])].map((label) => ({
display: (
<span className="capitalize flex items-center gap-x-2">
<span
className="w-3 h-3 rounded-full"
style={{
backgroundColor: label.color || "#000",
}}
/>
{label.name}
</span>
),
onClick: () => handleOnSelect("labels", label.id),
isSelected: store.issue.filteredLabels.includes(label.id),
})),
},
]}
/>
);
});

View File

@ -6,7 +6,8 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// ui // ui
import { ReactionSelector, Tooltip } from "components/ui"; import { ReactionSelector } from "components/ui";
import { Tooltip } from "@plane/ui";
// helpers // helpers
import { groupReactions, renderEmoji } from "helpers/emoji.helper"; import { groupReactions, renderEmoji } from "helpers/emoji.helper";

View File

@ -7,7 +7,7 @@ import {
PeekOverviewIssueProperties, PeekOverviewIssueProperties,
} from "components/issues/peek-overview"; } from "components/issues/peek-overview";
// types // types
import { Loader } from "components/ui/loader"; import { Loader } from "@plane/ui";
import { IIssue } from "types/issue"; import { IIssue } from "types/issue";
type Props = { type Props = {

View File

@ -10,7 +10,8 @@ import { useMobxStore } from "lib/mobx/store-provider";
// components // components
import { CommentCard, AddComment } from "components/issues/peek-overview"; import { CommentCard, AddComment } from "components/issues/peek-overview";
// ui // ui
import { Icon, PrimaryButton } from "components/ui"; import { Icon } from "components/ui";
import { Button } from "@plane/ui";
// types // types
import { IIssue } from "types/issue"; import { IIssue } from "types/issue";
@ -55,9 +56,7 @@ export const PeekOverviewIssueActivity: React.FC<Props> = observer(() => {
Sign in to add your comment Sign in to add your comment
</p> </p>
<Link href={`/?next_path=${router.asPath}`}> <Link href={`/?next_path=${router.asPath}`}>
<span> <Button variant="primary">Sign in</Button>
<PrimaryButton className="flex-shrink-0 !px-7">Sign in</PrimaryButton>
</span>
</Link> </Link>
</div> </div>
)} )}

View File

@ -6,7 +6,8 @@ import { useMobxStore } from "lib/mobx/store-provider";
// helpers // helpers
import { groupReactions, renderEmoji } from "helpers/emoji.helper"; import { groupReactions, renderEmoji } from "helpers/emoji.helper";
// components // components
import { ReactionSelector, Tooltip } from "components/ui"; import { ReactionSelector } from "components/ui";
import { Tooltip } from "@plane/ui";
export const IssueEmojiReactions: React.FC = observer(() => { export const IssueEmojiReactions: React.FC = observer(() => {
// router // router

View File

@ -6,7 +6,8 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// lib // lib
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
import { Tooltip } from "components/ui"; // ui
import { Tooltip } from "@plane/ui";
export const IssueVotes: React.FC = observer(() => { export const IssueVotes: React.FC = observer(() => {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);

View File

@ -7,7 +7,7 @@ import {
PeekOverviewIssueProperties, PeekOverviewIssueProperties,
} from "components/issues/peek-overview"; } from "components/issues/peek-overview";
import { Loader } from "components/ui/loader"; import { Loader } from "@plane/ui";
import { IIssue } from "types/issue"; import { IIssue } from "types/issue";
type Props = { type Props = {

View File

@ -1,8 +1,3 @@
export * from "./dropdown"; export * from "./dropdown";
export * from "./input";
export * from "./loader";
export * from "./primary-button";
export * from "./secondary-button";
export * from "./icon"; export * from "./icon";
export * from "./reaction-selector"; export * from "./reaction-selector";
export * from "./tooltip";

View File

@ -1,37 +0,0 @@
import React, { forwardRef, Ref } from "react";
// types
interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
mode?: "primary" | "transparent" | "trueTransparent";
error?: boolean;
inputSize?: "rg" | "lg";
fullWidth?: boolean;
}
export const Input = forwardRef((props: Props, ref: Ref<HTMLInputElement>) => {
const { mode = "primary", error, className = "", type, fullWidth = true, id, inputSize = "rg", ...rest } = props;
return (
<input
id={id}
ref={ref}
type={type}
className={`block rounded-md bg-transparent text-sm focus:outline-none placeholder-custom-text-400 ${
mode === "primary"
? "rounded-md border border-custom-border-200"
: mode === "transparent"
? "rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary"
: mode === "trueTransparent"
? "rounded border-none bg-transparent ring-0"
: ""
} ${error ? "border-red-500" : ""} ${error && mode === "primary" ? "bg-red-500/20" : ""} ${
fullWidth ? "w-full" : ""
} ${inputSize === "rg" ? "px-3 py-2" : inputSize === "lg" ? "p-3" : ""} ${className}`}
{...rest}
/>
);
});
Input.displayName = "Input";
export default Input;

View File

@ -1,25 +0,0 @@
import React from "react";
type Props = {
children: React.ReactNode;
className?: string;
};
const Loader = ({ children, className = "" }: Props) => (
<div className={`${className} animate-pulse`} role="status">
{children}
</div>
);
type ItemProps = {
height?: string;
width?: string;
};
const Item: React.FC<ItemProps> = ({ height = "auto", width = "auto" }) => (
<div className="rounded-md bg-custom-background-80" style={{ height: height, width: width }} />
);
Loader.Item = Item;
export { Loader };

View File

@ -1,35 +0,0 @@
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: "sm" | "md" | "lg";
outline?: boolean;
loading?: boolean;
}
export const PrimaryButton: React.FC<ButtonProps> = ({
children,
className = "",
onClick,
type = "button",
disabled = false,
loading = false,
size = "sm",
outline = false,
}) => (
<button
type={type}
className={`${className} border border-custom-primary font-medium duration-300 ${
size === "sm"
? "rounded px-3 py-2 text-xs"
: size === "md"
? "rounded-md px-3.5 py-2 text-sm"
: "rounded-lg px-4 py-2 text-base"
} ${disabled ? "cursor-not-allowed opacity-70 hover:opacity-70" : ""} ${
outline
? "bg-transparent text-custom-primary hover:bg-custom-primary hover:text-white"
: "text-white bg-custom-primary hover:border-opacity-90 hover:bg-opacity-90"
} ${loading ? "cursor-wait" : ""}`}
onClick={onClick}
disabled={disabled || loading}
>
{children}
</button>
);

View File

@ -1,35 +0,0 @@
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: "sm" | "md" | "lg";
outline?: boolean;
loading?: boolean;
}
export const SecondaryButton: React.FC<ButtonProps> = ({
children,
className = "",
onClick,
type = "button",
disabled = false,
loading = false,
size = "sm",
outline = false,
}) => (
<button
type={type}
className={`${className} border border-custom-border-200 font-medium duration-300 ${
size === "sm"
? "rounded px-3 py-2 text-xs"
: size === "md"
? "rounded-md px-3.5 py-2 text-sm"
: "rounded-lg px-4 py-2 text-base"
} ${disabled ? "cursor-not-allowed border-custom-border-200 bg-custom-background-90" : ""} ${
outline
? "bg-transparent hover:bg-custom-background-80"
: "bg-custom-background-100 hover:border-opacity-70 hover:bg-opacity-70"
} ${loading ? "cursor-wait" : ""}`}
onClick={onClick}
disabled={disabled || loading}
>
{children}
</button>
);

View File

@ -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<Props> = ({
tooltipHeading,
tooltipContent,
position = "top",
children,
disabled = false,
className = "",
openDelay = 200,
closeDelay,
}) => {
const { theme } = useTheme();
return (
<Tooltip2
disabled={disabled}
hoverOpenDelay={openDelay}
hoverCloseDelay={closeDelay}
content={
<div
className={`relative z-50 max-w-xs gap-1 rounded-md border border-custom-border-200 p-2 text-xs shadow-md ${
theme === "custom" ? "bg-custom-background-100 text-custom-text-200" : "bg-black text-gray-400"
} overflow-hidden break-words ${className}`}
>
{tooltipHeading && (
<h5 className={`font-medium ${theme === "custom" ? "text-custom-text-100" : "text-white"}`}>
{tooltipHeading}
</h5>
)}
{tooltipContent}
</div>
}
position={position}
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
}
/>
);
};

View File

@ -9,6 +9,7 @@ import { IssueCalendarView } from "components/issues/board-views/calendar";
import { IssueSpreadsheetView } from "components/issues/board-views/spreadsheet"; import { IssueSpreadsheetView } from "components/issues/board-views/spreadsheet";
import { IssueGanttView } from "components/issues/board-views/gantt"; import { IssueGanttView } from "components/issues/board-views/gantt";
import { IssuePeekOverview } from "components/issues/peek-overview"; import { IssuePeekOverview } from "components/issues/peek-overview";
import { IssueAppliedFilters } from "components/issues/filters/applied-filters/root";
// mobx store // mobx store
import { RootStore } from "store/root"; import { RootStore } from "store/root";
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
@ -71,7 +72,10 @@ export const ProjectDetailsView = observer(() => {
</div> </div>
) : ( ) : (
projectStore?.activeBoard && ( projectStore?.activeBoard && (
<> <div className="relative w-full h-full overflow-hidden flex flex-col">
{/* applied filters */}
<IssueAppliedFilters />
{projectStore?.activeBoard === "list" && ( {projectStore?.activeBoard === "list" && (
<div className="relative h-full w-full overflow-y-auto"> <div className="relative h-full w-full overflow-y-auto">
<IssueListView /> <IssueListView />
@ -85,7 +89,7 @@ export const ProjectDetailsView = observer(() => {
{projectStore?.activeBoard === "calendar" && <IssueCalendarView />} {projectStore?.activeBoard === "calendar" && <IssueCalendarView />}
{projectStore?.activeBoard === "spreadsheet" && <IssueSpreadsheetView />} {projectStore?.activeBoard === "spreadsheet" && <IssueSpreadsheetView />}
{projectStore?.activeBoard === "gantt" && <IssueGanttView />} {projectStore?.activeBoard === "gantt" && <IssueGanttView />}
</> </div>
) )
)} )}
</> </>

View File

@ -1,4 +1,3 @@
import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
// mobx // mobx

View File

@ -1,4 +1,4 @@
import { observable, action, computed, makeObservable, runInAction, reaction } from "mobx"; import { observable, action, computed, makeObservable, runInAction } from "mobx";
// services // services
import IssueService from "services/issue.service"; import IssueService from "services/issue.service";
// store // store

View File

@ -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;
};
}

View File

@ -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;
};

View File

@ -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<IFiltersOptions>;
}
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;
}
}

View File

@ -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;
}

View File

@ -2,6 +2,7 @@
import { observable, action, makeObservable, runInAction } from "mobx"; import { observable, action, makeObservable, runInAction } from "mobx";
// service // service
import ProjectService from "services/project.service"; import ProjectService from "services/project.service";
import { TIssueBoardKeys } from "types/issue";
// types // types
import { IWorkspace, IProject, IProjectSettings } from "types/project"; import { IWorkspace, IProject, IProjectSettings } from "types/project";
@ -12,9 +13,9 @@ export interface IProjectStore {
project: IProject | null; project: IProject | null;
deploySettings: IProjectSettings | null; deploySettings: IProjectSettings | null;
viewOptions: any; viewOptions: any;
activeBoard: string | null; activeBoard: TIssueBoardKeys | null;
fetchProjectSettings: (workspace_slug: string, project_slug: string) => Promise<void>; fetchProjectSettings: (workspace_slug: string, project_slug: string) => Promise<void>;
setActiveBoard: (value: string) => void; setActiveBoard: (value: TIssueBoardKeys) => void;
} }
class ProjectStore implements IProjectStore { class ProjectStore implements IProjectStore {
@ -25,7 +26,7 @@ class ProjectStore implements IProjectStore {
project: IProject | null = null; project: IProject | null = null;
deploySettings: IProjectSettings | null = null; deploySettings: IProjectSettings | null = null;
viewOptions: any = null; viewOptions: any = null;
activeBoard: string | null = null; activeBoard: TIssueBoardKeys | null = null;
// root store // root store
rootStore; rootStore;
// service // service
@ -80,7 +81,7 @@ class ProjectStore implements IProjectStore {
} }
}; };
setActiveBoard = (boardValue: string) => { setActiveBoard = (boardValue: TIssueBoardKeys) => {
this.activeBoard = boardValue; this.activeBoard = boardValue;
}; };
} }

View File

@ -6,6 +6,7 @@ import IssueStore, { IIssueStore } from "./issue";
import ProjectStore, { IProjectStore } from "./project"; import ProjectStore, { IProjectStore } from "./project";
import IssueDetailStore, { IIssueDetailStore } from "./issue_details"; import IssueDetailStore, { IIssueDetailStore } from "./issue_details";
import { IMentionsStore, MentionsStore } from "./mentions.store"; import { IMentionsStore, MentionsStore } from "./mentions.store";
import { IIssuesFilterStore, IssuesFilterStore } from "./issues/issue-filters.store";
enableStaticRendering(typeof window === "undefined"); enableStaticRendering(typeof window === "undefined");
@ -15,6 +16,7 @@ export class RootStore {
issueDetails: IIssueDetailStore; issueDetails: IIssueDetailStore;
project: IProjectStore; project: IProjectStore;
mentionsStore: IMentionsStore; mentionsStore: IMentionsStore;
issuesFilter: IIssuesFilterStore;
constructor() { constructor() {
this.user = new UserStore(this); this.user = new UserStore(this);
@ -22,5 +24,6 @@ export class RootStore {
this.project = new ProjectStore(this); this.project = new ProjectStore(this);
this.issueDetails = new IssueDetailStore(this); this.issueDetails = new IssueDetailStore(this);
this.mentionsStore = new MentionsStore(this); this.mentionsStore = new MentionsStore(this);
this.issuesFilter = new IssuesFilterStore(this);
} }
} }

View File

@ -84,7 +84,7 @@ export const EmailForm: React.FC<Props> = (props) => {
return ( return (
<> <>
<h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100"> <h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">
Get on your flight deck! Get on your flight deck
</h1> </h1>
<p className="text-center text-sm text-onboarding-text-200 mt-3"> <p className="text-center text-sm text-onboarding-text-200 mt-3">
Sign in with the email you used to sign up for Plane Sign in with the email you used to sign up for Plane

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react"; import { XCircle } from "lucide-react";
@ -36,6 +36,8 @@ const authService = new AuthService();
export const PasswordForm: React.FC<Props> = (props) => { export const PasswordForm: React.FC<Props> = (props) => {
const { email, updateEmail, handleStepChange, handleSignInRedirection } = props; const { email, updateEmail, handleStepChange, handleSignInRedirection } = props;
// states
const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false);
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// form info // form info
@ -113,6 +115,8 @@ export const PasswordForm: React.FC<Props> = (props) => {
return; return;
} }
setIsSendingResetPasswordLink(true);
authService authService
.sendResetPasswordLink({ email: emailFormValue }) .sendResetPasswordLink({ email: emailFormValue })
.then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK)) .then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK))
@ -122,7 +126,8 @@ export const PasswordForm: React.FC<Props> = (props) => {
title: "Error!", title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.", message: err?.error ?? "Something went wrong. Please try again.",
}) })
); )
.finally(() => setIsSendingResetPasswordLink(false));
}; };
return ( return (
@ -189,9 +194,12 @@ export const PasswordForm: React.FC<Props> = (props) => {
<button <button
type="button" type="button"
onClick={handleForgotPassword} onClick={handleForgotPassword}
className="text-xs font-medium text-custom-primary-100" className={`text-xs font-medium ${
isSendingResetPasswordLink ? "text-onboarding-text-300" : "text-custom-primary-100"
}`}
disabled={isSendingResetPasswordLink}
> >
Forgot your password? {isSendingResetPasswordLink ? "Sending link..." : "Forgot your password?"}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
// hooks
import useSignInRedirection from "hooks/use-sign-in-redirection";
// components // components
import { import {
EmailForm, EmailForm,
@ -19,33 +21,27 @@ export enum ESignInSteps {
CREATE_PASSWORD = "CREATE_PASSWORD", CREATE_PASSWORD = "CREATE_PASSWORD",
} }
type Props = {
handleSignInRedirection: () => Promise<void>;
};
const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD]; const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD];
export const SignInRoot: React.FC<Props> = (props) => { export const SignInRoot = () => {
const { handleSignInRedirection } = props;
// states // states
const [signInStep, setSignInStep] = useState<ESignInSteps>(ESignInSteps.EMAIL); const [signInStep, setSignInStep] = useState<ESignInSteps>(ESignInSteps.EMAIL);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
// sign in redirection hook
const { handleRedirection } = useSignInRedirection();
return ( return (
<> <>
<div className="mx-auto flex flex-col"> <div className="mx-auto flex flex-col">
{signInStep === ESignInSteps.EMAIL && ( {signInStep === ESignInSteps.EMAIL && (
<EmailForm <EmailForm handleStepChange={(step) => setSignInStep(step)} updateEmail={(newEmail) => setEmail(newEmail)} />
handleStepChange={(step: ESignInSteps) => setSignInStep(step)}
updateEmail={(newEmail) => setEmail(newEmail)}
/>
)} )}
{signInStep === ESignInSteps.PASSWORD && ( {signInStep === ESignInSteps.PASSWORD && (
<PasswordForm <PasswordForm
email={email} email={email}
updateEmail={(newEmail) => setEmail(newEmail)} updateEmail={(newEmail) => setEmail(newEmail)}
handleStepChange={(step: ESignInSteps) => setSignInStep(step)} handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleSignInRedirection} handleSignInRedirection={handleRedirection}
/> />
)} )}
{signInStep === ESignInSteps.SET_PASSWORD_LINK && ( {signInStep === ESignInSteps.SET_PASSWORD_LINK && (
@ -55,30 +51,30 @@ export const SignInRoot: React.FC<Props> = (props) => {
<UniqueCodeForm <UniqueCodeForm
email={email} email={email}
updateEmail={(newEmail) => setEmail(newEmail)} updateEmail={(newEmail) => setEmail(newEmail)}
handleStepChange={(step: ESignInSteps) => setSignInStep(step)} handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleSignInRedirection} handleSignInRedirection={handleRedirection}
/> />
)} )}
{signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && (
<OptionalSetPasswordForm <OptionalSetPasswordForm
email={email} email={email}
handleStepChange={(step: ESignInSteps) => setSignInStep(step)} handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleSignInRedirection} handleSignInRedirection={handleRedirection}
/> />
)} )}
{signInStep === ESignInSteps.CREATE_PASSWORD && ( {signInStep === ESignInSteps.CREATE_PASSWORD && (
<CreatePasswordForm <CreatePasswordForm
email={email} email={email}
handleStepChange={(step: ESignInSteps) => setSignInStep(step)} handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleSignInRedirection} handleSignInRedirection={handleRedirection}
/> />
)} )}
</div> </div>
{!OAUTH_HIDDEN_STEPS.includes(signInStep) && ( {!OAUTH_HIDDEN_STEPS.includes(signInStep) && (
<OAuthOptions <OAuthOptions
updateEmail={(newEmail) => setEmail(newEmail)} updateEmail={(newEmail) => setEmail(newEmail)}
handleStepChange={(step: ESignInSteps) => setSignInStep(step)} handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleSignInRedirection} handleSignInRedirection={handleRedirection}
/> />
)} )}
</> </>

View File

@ -30,7 +30,7 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
const { const {
control, control,
formState: { errors, isValid }, formState: { errors, isValid },
watch, handleSubmit,
} = useForm({ } = useForm({
defaultValues: { defaultValues: {
email, email,
@ -39,11 +39,13 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
reValidateMode: "onChange", reValidateMode: "onChange",
}); });
const handleSendNewLink = async () => { const handleSendNewLink = async (formData: { email: string }) => {
setIsSendingNewLink(true); setIsSendingNewLink(true);
updateEmail(formData.email);
const payload: IEmailCheckData = { const payload: IEmailCheckData = {
email: watch("email"), email: formData.email,
type: "password", type: "password",
}; };
@ -76,7 +78,7 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
password password
</p> </p>
<form className="mt-5 sm:w-96 mx-auto space-y-4"> <form onSubmit={handleSubmit(handleSendNewLink)} className="mt-5 sm:w-96 mx-auto space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<Controller <Controller
control={control} control={control}
@ -92,10 +94,7 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
name="email" name="email"
type="email" type="email"
value={value} value={value}
onChange={(e) => { onChange={onChange}
updateEmail(e.target.value);
onChange(e.target.value);
}}
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@firstflight.com" placeholder="orville.wright@firstflight.com"
@ -112,11 +111,10 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
/> />
</div> </div>
<Button <Button
type="button" type="submit"
variant="primary" variant="primary"
className="w-full" className="w-full"
size="xl" size="xl"
onClick={handleSendNewLink}
disabled={!isValid} disabled={!isValid}
loading={isSendingNewLink} loading={isSendingNewLink}
> >

View File

@ -11,6 +11,7 @@ import { ArchiveRestore } from "lucide-react";
import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
// types // types
import { IProject } from "types"; import { IProject } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
type Props = { type Props = {
handleChange: (formData: Partial<IProject>) => Promise<void>; handleChange: (formData: Partial<IProject>) => Promise<void>;
@ -28,6 +29,8 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
const projectDetails = projectStore.currentProjectDetails; const projectDetails = projectStore.currentProjectDetails;
const userRole = userStore.currentProjectRole; const userRole = userStore.currentProjectRole;
const isAdmin = userRole === EUserWorkspaceRoles.ADMIN;
return ( return (
<> <>
<SelectMonthModal <SelectMonthModal
@ -56,7 +59,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
projectDetails?.archive_in === 0 ? handleChange({ archive_in: 1 }) : handleChange({ archive_in: 0 }) projectDetails?.archive_in === 0 ? handleChange({ archive_in: 1 }) : handleChange({ archive_in: 0 })
} }
size="sm" size="sm"
disabled={userRole !== 20} disabled={!isAdmin}
/> />
</div> </div>
@ -74,7 +77,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
}} }}
input input
width="w-full" width="w-full"
disabled={userRole !== 20} disabled={!isAdmin}
> >
<> <>
{PROJECT_AUTOMATION_MONTHS.map((month) => ( {PROJECT_AUTOMATION_MONTHS.map((month) => (

View File

@ -11,6 +11,7 @@ import { ArchiveX } from "lucide-react";
import { IProject } from "types"; import { IProject } from "types";
// fetch keys // fetch keys
import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
import { EUserWorkspaceRoles } from "constants/workspace";
type Props = { type Props = {
handleChange: (formData: Partial<IProject>) => Promise<void>; handleChange: (formData: Partial<IProject>) => Promise<void>;
@ -53,6 +54,8 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
default_state: defaultState, default_state: defaultState,
}; };
const isAdmin = userRole === EUserWorkspaceRoles.ADMIN;
return ( return (
<> <>
<SelectMonthModal <SelectMonthModal
@ -83,7 +86,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
: handleChange({ close_in: 0, default_state: null }) : handleChange({ close_in: 0, default_state: null })
} }
size="sm" size="sm"
disabled={userRole !== 20} disabled={!isAdmin}
/> />
</div> </div>
@ -102,7 +105,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
}} }}
input input
width="w-full" width="w-full"
disabled={userRole !== 20} disabled={!isAdmin}
> >
<> <>
{PROJECT_AUTOMATION_MONTHS.map((month) => ( {PROJECT_AUTOMATION_MONTHS.map((month) => (

View File

@ -324,7 +324,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
File formats supported- .jpeg, .jpg, .png, .webp, .svg File formats supported- .jpeg, .jpg, .png, .webp, .svg
</p> </p>
<div className="flex items-center justify-end gap-2"> <div className="flex items-start h-12 justify-end gap-2">
<Button <Button
variant="neutral-primary" variant="neutral-primary"
onClick={() => { onClick={() => {

View File

@ -52,7 +52,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query; const { workspaceSlug, projectId, peekCycle } = router.query;
const { cycle: cycleDetailsStore, trackEvent: { setTrackElement, postHogEventTracker } } = useMobxStore(); const {
cycle: cycleDetailsStore,
trackEvent: { setTrackElement, postHogEventTracker },
} = useMobxStore();
const cycleDetails = cycleDetailsStore.cycle_details[cycleId] ?? undefined; const cycleDetails = cycleDetailsStore.cycle_details[cycleId] ?? undefined;
@ -70,31 +73,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const submitChanges = (data: Partial<ICycle>) => { const submitChanges = (data: Partial<ICycle>) => {
if (!workspaceSlug || !projectId || !cycleId) return; if (!workspaceSlug || !projectId || !cycleId) return;
mutate<ICycle>(CYCLE_DETAILS(cycleId as string), (prevData) => ({ ...(prevData as ICycle), ...data }), false); cycleDetailsStore.patchCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data);
cycleService
.patchCycle(workspaceSlug as string, projectId as string, cycleId as string, data)
.then((res) => {
mutate(CYCLE_DETAILS(cycleId as string));
postHogEventTracker(
"CYCLE_UPDATE",
{
...res,
state: "SUCCESS"
}
);
}
)
.catch((e) => {
console.log(e);
postHogEventTracker(
"CYCLE_UPDATE",
{
state: "FAILED"
}
);
}
);
}; };
const handleCopyText = () => { const handleCopyText = () => {
@ -337,11 +316,12 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</button> </button>
{!isCompleted && ( {!isCompleted && (
<CustomMenu width="lg" placement="bottom-end" ellipsis> <CustomMenu width="lg" placement="bottom-end" ellipsis>
<CustomMenu.MenuItem onClick={() => { <CustomMenu.MenuItem
onClick={() => {
setTrackElement("CYCLE_PAGE_SIDEBAR"); setTrackElement("CYCLE_PAGE_SIDEBAR");
setCycleDeleteModal(true) setCycleDeleteModal(true);
} }}
}> >
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
<span>Delete cycle</span> <span>Delete cycle</span>

View File

@ -2,10 +2,12 @@ import React, { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
// services // services
import { CycleService } from "services/cycle.service"; import { CycleService } from "services/cycle.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
//icons //icons
import { ContrastIcon, TransferIcon } from "@plane/ui"; import { ContrastIcon, TransferIcon } from "@plane/ui";
import { AlertCircle, Search, X } from "lucide-react"; import { AlertCircle, Search, X } from "lucide-react";
@ -23,17 +25,19 @@ type Props = {
const cycleService = new CycleService(); const cycleService = new CycleService();
export const TransferIssuesModal: React.FC<Props> = ({ isOpen, handleClose }) => { export const TransferIssuesModal: React.FC<Props> = observer(({ isOpen, handleClose }) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const { cycleIssues: cycleIssueStore } = useMobxStore();
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query; const { workspaceSlug, projectId, cycleId } = router.query;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const transferIssue = async (payload: any) => { const transferIssue = async (payload: any) => {
await cycleService await cycleIssueStore
.transferIssues(workspaceSlug as string, projectId as string, cycleId as string, payload) .transferIssuesFromCycle(workspaceSlug as string, projectId as string, cycleId as string, payload)
.then(() => { .then(() => {
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -159,4 +163,4 @@ export const TransferIssuesModal: React.FC<Props> = ({ isOpen, handleClose }) =>
</Dialog> </Dialog>
</Transition.Root> </Transition.Root>
); );
}; });

View File

@ -22,6 +22,7 @@ import { Button } from "@plane/ui";
import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Inbox, Trash2, XCircle } from "lucide-react"; import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Inbox, Trash2, XCircle } from "lucide-react";
// types // types
import type { TInboxStatus } from "types"; import type { TInboxStatus } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
export const InboxActionsHeader = observer(() => { export const InboxActionsHeader = observer(() => {
const [date, setDate] = useState(new Date()); const [date, setDate] = useState(new Date());
@ -71,7 +72,7 @@ export const InboxActionsHeader = observer(() => {
}, [issue]); }, [issue]);
const issueStatus = issue?.issue_inbox[0].status; const issueStatus = issue?.issue_inbox[0].status;
const isAllowed = userRole === 15 || userRole === 20; const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER;
const today = new Date(); const today = new Date();
const tomorrow = new Date(today); const tomorrow = new Date(today);

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useState } from "react";
import Router, { useRouter } from "next/router"; import Router, { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import useSWR from "swr"; import useSWR from "swr";
@ -8,14 +8,15 @@ import { AlertTriangle, CheckCircle2, Clock, Copy, ExternalLink, Inbox, XCircle
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// components // components
import { IssueDescriptionForm, IssueDetailsSidebar, IssueReaction } from "components/issues"; import { IssueDescriptionForm, IssueDetailsSidebar, IssueReaction, IssueUpdateStatus } from "components/issues";
import { InboxIssueActivity } from "components/inbox"; import { InboxIssueActivity } from "components/inbox";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader, StateGroupIcon } from "@plane/ui";
// helpers // helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types // types
import { IInboxIssue, IIssue } from "types"; import { IInboxIssue, IIssue } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
const defaultValues: Partial<IInboxIssue> = { const defaultValues: Partial<IInboxIssue> = {
name: "", name: "",
@ -30,7 +31,15 @@ export const InboxMainContent: React.FC = observer(() => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query; 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 user = userStore.currentUser;
const userRole = userStore.currentProjectRole; const userRole = userStore.currentProjectRole;
@ -54,6 +63,9 @@ export const InboxMainContent: React.FC = observer(() => {
const issuesList = inboxId ? inboxIssuesStore.inboxIssues[inboxId.toString()] : undefined; const issuesList = inboxId ? inboxIssuesStore.inboxIssues[inboxId.toString()] : undefined;
const issueDetails = inboxIssueId ? inboxIssueDetailsStore.issueDetails[inboxIssueId.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( const submitChanges = useCallback(
async (formData: Partial<IInboxIssue>) => { async (formData: Partial<IInboxIssue>) => {
@ -144,6 +156,8 @@ export const InboxMainContent: React.FC = observer(() => {
</div> </div>
); );
const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER;
return ( return (
<> <>
{issueDetails ? ( {issueDetails ? (
@ -214,15 +228,27 @@ export const InboxMainContent: React.FC = observer(() => {
</> </>
) : null} ) : null}
</div> </div>
<div className="flex items-center mb-5">
{currentIssueState && (
<StateGroupIcon
className="h-4 w-4 mr-3"
stateGroup={currentIssueState.group}
color={currentIssueState.color}
/>
)}
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issueDetails} />
</div>
<div> <div>
<IssueDescriptionForm <IssueDescriptionForm
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
workspaceSlug={workspaceSlug as string} workspaceSlug={workspaceSlug as string}
issue={{ issue={{
name: issueDetails.name, name: issueDetails.name,
description_html: issueDetails.description_html, description_html: issueDetails.description_html,
}} }}
handleFormSubmit={submitChanges} handleFormSubmit={submitChanges}
isAllowed={userRole === 15 || userRole === 20 || user?.id === issueDetails.created_by} isAllowed={isAllowed || user?.id === issueDetails.created_by}
/> />
</div> </div>

View File

@ -26,14 +26,15 @@ export interface IssueDetailsProps {
workspaceSlug: string; workspaceSlug: string;
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>; handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
isAllowed: boolean; isAllowed: boolean;
isSubmitting: "submitting" | "submitted" | "saved";
setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void;
} }
const fileService = new FileService(); const fileService = new FileService();
export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => { export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
const { issue, handleFormSubmit, workspaceSlug, isAllowed } = props; const { issue, handleFormSubmit, workspaceSlug, isAllowed, isSubmitting, setIsSubmitting } = props;
// states // states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const [characterLimit, setCharacterLimit] = useState(false); const [characterLimit, setCharacterLimit] = useState(false);
const { setShowAlert } = useReloadConfirmations(); const { setShowAlert } = useReloadConfirmations();
@ -166,13 +167,6 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
/> />
)} )}
/> />
<div
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
>
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
</div>
</div> </div>
</div> </div>
); );

View File

@ -15,6 +15,7 @@ export * from "./sidebar";
export * from "./label"; export * from "./label";
export * from "./issue-reaction"; export * from "./issue-reaction";
export * from "./confirm-issue-discard"; export * from "./confirm-issue-discard";
export * from "./issue-update-status";
// draft issue // draft issue
export * from "./draft-issue-form"; export * from "./draft-issue-form";

View File

@ -25,6 +25,7 @@ import {
IViewIssuesStore, IViewIssuesStore,
} from "store/issues"; } from "store/issues";
import { TUnGroupedIssues } from "store/issues/types"; import { TUnGroupedIssues } from "store/issues/types";
import { EUserWorkspaceRoles } from "constants/workspace";
interface IBaseGanttRoot { interface IBaseGanttRoot {
issueFiltersStore: issueFiltersStore:
@ -69,7 +70,7 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
); );
}; };
const isAllowed = currentProjectRole && currentProjectRole >= 15; const isAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
return ( return (
<> <>

View File

@ -31,6 +31,7 @@ import { KanBan } from "./default";
import { KanBanSwimLanes } from "./swimlanes"; import { KanBanSwimLanes } from "./swimlanes";
import { EProjectStore } from "store/command-palette.store"; import { EProjectStore } from "store/command-palette.store";
import { IssuePeekOverview } from "components/issues"; import { IssuePeekOverview } from "components/issues";
import { EUserWorkspaceRoles } from "constants/workspace";
export interface IBaseKanBanLayout { export interface IBaseKanBanLayout {
issueStore: issueStore:
@ -93,7 +94,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
} = useMobxStore(); } = useMobxStore();
const { currentProjectRole } = userStore; const { currentProjectRole } = userStore;
const isEditingAllowed = [15, 20].includes(currentProjectRole || 0); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
const issues = issueStore?.getIssues || {}; const issues = issueStore?.getIssues || {};
const issueIds = issueStore?.getIssuesIds || []; const issueIds = issueStore?.getIssuesIds || [];
@ -223,7 +224,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
isDragStarted={isDragStarted} isDragStarted={isDragStarted}
quickAddCallback={issueStore?.quickAddIssue} quickAddCallback={issueStore?.quickAddIssue}
viewId={viewId} viewId={viewId}
disableIssueCreation={!enableIssueCreation} disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
isReadOnly={!enableInlineEditing || !isEditingAllowed} isReadOnly={!enableInlineEditing || !isEditingAllowed}
currentStore={currentStore} currentStore={currentStore}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}

View File

@ -1,4 +1,6 @@
import { memo } from "react";
import { Draggable } from "@hello-pangea/dnd"; import { Draggable } from "@hello-pangea/dnd";
import isEqual from "lodash/isEqual";
// components // components
import { KanBanProperties } from "./properties"; import { KanBanProperties } from "./properties";
// ui // ui
@ -21,7 +23,7 @@ interface IssueBlockProps {
isReadOnly: boolean; isReadOnly: boolean;
} }
export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => { export const KanBanIssueMemoBlock: React.FC<IssueBlockProps> = (props) => {
const { const {
sub_group_id, sub_group_id,
columnId, columnId,
@ -63,30 +65,36 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}
ref={provided.innerRef} ref={provided.innerRef}
onClick={handleIssuePeekOverview}
> >
{issue.tempId !== undefined && ( {issue.tempId !== undefined && (
<div className="absolute top-0 left-0 w-full h-full animate-pulse bg-custom-background-100/20 z-[99999]" /> <div className="absolute top-0 left-0 w-full h-full animate-pulse bg-custom-background-100/20 z-[99999]" />
)} )}
<div className="absolute top-3 right-3 hidden group-hover/kanban-block:block">
{quickActions(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!columnId && columnId === "null" ? null : columnId,
issue
)}
</div>
<div <div
className={`text-sm rounded py-2 px-3 shadow-custom-shadow-2xs space-y-2 border-[0.5px] border-custom-border-200 transition-all bg-custom-background-100 ${ className={`text-sm rounded py-2 px-3 shadow-custom-shadow-2xs space-y-2 border-[0.5px] border-custom-border-200 transition-all bg-custom-background-100 ${
isDragDisabled ? "" : "hover:cursor-grab" isDragDisabled ? "" : "hover:cursor-grab"
} ${snapshot.isDragging ? `border-custom-primary-100` : `border-transparent`}`} } ${snapshot.isDragging ? `border-custom-primary-100` : `border-transparent`}`}
> >
{displayProperties && displayProperties?.key && ( {displayProperties && displayProperties?.key && (
<div className="relative">
<div className="text-xs line-clamp-1 text-custom-text-300"> <div className="text-xs line-clamp-1 text-custom-text-300">
{issue.project_detail.identifier}-{issue.sequence_id} {issue.project_detail.identifier}-{issue.sequence_id}
</div> </div>
<div className="absolute -top-1 right-0 hidden group-hover/kanban-block:block">
{quickActions(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!columnId && columnId === "null" ? null : columnId,
issue
)}
</div>
</div>
)} )}
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}> <Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
<div className="line-clamp-2 text-sm font-medium text-custom-text-100">{issue.name}</div> <div
className="line-clamp-2 text-sm font-medium text-custom-text-100"
onClick={handleIssuePeekOverview}
>
{issue.name}
</div>
</Tooltip> </Tooltip>
<div> <div>
<KanBanProperties <KanBanProperties
@ -106,3 +114,10 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
</> </>
); );
}; };
const validateMemo = (prevProps: IssueBlockProps, nextProps: IssueBlockProps) => {
if (prevProps.issue != nextProps.issue) return true;
return false;
};
export const KanbanIssueBlock = memo(KanBanIssueMemoBlock, validateMemo);

View File

@ -3,6 +3,7 @@ import { useRouter } from "next/router";
// components // components
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
import { CreateUpdateIssueModal } from "components/issues/modal"; import { CreateUpdateIssueModal } from "components/issues/modal";
import { CreateUpdateDraftIssueModal } from "components/issues/draft-issue-modal";
import { ExistingIssuesListModal } from "components/core"; import { ExistingIssuesListModal } from "components/core";
// lucide icons // lucide icons
import { Minimize2, Maximize2, Circle, Plus } from "lucide-react"; import { Minimize2, Maximize2, Circle, Plus } from "lucide-react";
@ -51,6 +52,8 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, moduleId, cycleId } = router.query; const { workspaceSlug, projectId, moduleId, cycleId } = router.query;
const isDraftIssue = router.pathname.includes("draft-issue");
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const renderExistingIssueModal = moduleId || cycleId; const renderExistingIssueModal = moduleId || cycleId;
@ -73,12 +76,21 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
return ( return (
<> <>
{isDraftIssue ? (
<CreateUpdateDraftIssueModal
isOpen={isOpen}
handleClose={() => setIsOpen(false)}
prePopulateData={issuePayload}
fieldsToShow={["all"]}
/>
) : (
<CreateUpdateIssueModal <CreateUpdateIssueModal
isOpen={isOpen} isOpen={isOpen}
handleClose={() => setIsOpen(false)} handleClose={() => setIsOpen(false)}
prePopulateData={issuePayload} prePopulateData={issuePayload}
currentStore={currentStore} currentStore={currentStore}
/> />
)}
{renderExistingIssueModal && ( {renderExistingIssueModal && (
<ExistingIssuesListModal <ExistingIssuesListModal
isOpen={openExistingIssueListModal} isOpen={openExistingIssueListModal}

View File

@ -25,6 +25,7 @@ import { IIssueResponse } from "store/issues/types";
import { EProjectStore } from "store/command-palette.store"; import { EProjectStore } from "store/command-palette.store";
import { IssuePeekOverview } from "components/issues"; import { IssuePeekOverview } from "components/issues";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { EUserWorkspaceRoles } from "constants/workspace";
enum EIssueActions { enum EIssueActions {
UPDATE = "update", UPDATE = "update",
@ -83,7 +84,7 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
} = useMobxStore(); } = useMobxStore();
const { currentProjectRole } = userStore; const { currentProjectRole } = userStore;
const isEditingAllowed = [15, 20].includes(currentProjectRole || 0); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
const issueIds = issueStore?.getIssuesIds || []; const issueIds = issueStore?.getIssuesIds || [];
const issues = issueStore?.getIssues; const issues = issueStore?.getIssues;
@ -147,7 +148,7 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
quickAddCallback={issueStore?.quickAddIssue} quickAddCallback={issueStore?.quickAddIssue}
enableIssueQuickAdd={!!enableQuickAdd} enableIssueQuickAdd={!!enableQuickAdd}
isReadonly={!enableInlineEditing || !isEditingAllowed} isReadonly={!enableInlineEditing || !isEditingAllowed}
disableIssueCreation={!enableIssueCreation} disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
currentStore={currentStore} currentStore={currentStore}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}
/> />

View File

@ -3,6 +3,7 @@ import { useRouter } from "next/router";
// lucide icons // lucide icons
import { CircleDashed, Plus } from "lucide-react"; import { CircleDashed, Plus } from "lucide-react";
// components // components
import { CreateUpdateDraftIssueModal } from "components/issues/draft-issue-modal";
import { CreateUpdateIssueModal } from "components/issues/modal"; import { CreateUpdateIssueModal } from "components/issues/modal";
import { ExistingIssuesListModal } from "components/core"; import { ExistingIssuesListModal } from "components/core";
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
@ -32,6 +33,8 @@ export const HeaderGroupByCard = observer(
const [openExistingIssueListModal, setOpenExistingIssueListModal] = React.useState(false); const [openExistingIssueListModal, setOpenExistingIssueListModal] = React.useState(false);
const isDraftIssue = router.pathname.includes("draft-issue");
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const renderExistingIssueModal = moduleId || cycleId; const renderExistingIssueModal = moduleId || cycleId;
@ -90,12 +93,21 @@ export const HeaderGroupByCard = observer(
</div> </div>
))} ))}
{isDraftIssue ? (
<CreateUpdateDraftIssueModal
isOpen={isOpen}
handleClose={() => setIsOpen(false)}
prePopulateData={issuePayload}
fieldsToShow={["all"]}
/>
) : (
<CreateUpdateIssueModal <CreateUpdateIssueModal
isOpen={isOpen} isOpen={isOpen}
handleClose={() => setIsOpen(false)} handleClose={() => setIsOpen(false)}
currentStore={currentStore} currentStore={currentStore}
prePopulateData={issuePayload} prePopulateData={issuePayload}
/> />
)}
{renderExistingIssueModal && ( {renderExistingIssueModal && (
<ExistingIssuesListModal <ExistingIssuesListModal

Some files were not shown because too many files have changed in this diff Show More