forked from github/plane
Merge branch 'develop' of github.com:makeplane/plane into chore/space_sign_in_improvement
This commit is contained in:
commit
b5ad8d282b
@ -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"
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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 (
|
||||||
@ -60,6 +67,15 @@ class IssueSerializer(BaseSerializer):
|
|||||||
and data.get("start_date", None) > data.get("target_date", None)
|
and data.get("start_date", None) > data.get("target_date", None)
|
||||||
):
|
):
|
||||||
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", []):
|
||||||
@ -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
|
||||||
@ -349,4 +390,4 @@ class IssueExpandSerializer(BaseSerializer):
|
|||||||
"updated_by",
|
"updated_by",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
@ -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",
|
||||||
|
@ -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",
|
||||||
]
|
]
|
||||||
|
@ -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",
|
|
||||||
]
|
|
@ -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()
|
||||||
|
@ -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)
|
||||||
|
@ -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":
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
@ -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),
|
||||||
)
|
)
|
||||||
|
@ -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),
|
||||||
)
|
)
|
||||||
|
@ -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),
|
||||||
)
|
)
|
||||||
|
@ -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()
|
||||||
|
@ -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),
|
||||||
)
|
)
|
||||||
|
@ -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,24 +474,20 @@ 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"},
|
headers = {
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
"Content-Type": "application/json",
|
||||||
|
"x-instance-id": instance.instance_id,
|
||||||
|
"x-api-key": instance.api_key,
|
||||||
|
}
|
||||||
|
# create the payload
|
||||||
|
payload = {"is_signup_screen_visited": True}
|
||||||
|
_ = requests.patch(
|
||||||
|
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
|
||||||
|
headers=headers,
|
||||||
|
data=json.dumps(payload),
|
||||||
)
|
)
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-instance-id": instance.instance_id,
|
|
||||||
"x-api-key": instance.api_key,
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = {"is_signup_screen_visited": True}
|
|
||||||
response = requests.patch(
|
|
||||||
f"{license_engine_base_url}/api/instances/",
|
|
||||||
headers=headers,
|
|
||||||
data=json.dumps(payload),
|
|
||||||
)
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
@ -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),
|
||||||
)
|
)
|
||||||
|
@ -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=[
|
||||||
@ -324,4 +324,10 @@ 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")
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
|
@ -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
|
||||||
|
@ -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 <team@mailer.plane.so>"}
|
- 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}
|
||||||
|
@ -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 <team@mailer.plane.so>"
|
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=""
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -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;
|
|
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -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;
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
42
space/components/issues/filters/applied-filters/label.tsx
Normal file
42
space/components/issues/filters/applied-filters/label.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
31
space/components/issues/filters/applied-filters/priority.tsx
Normal file
31
space/components/issues/filters/applied-filters/priority.tsx
Normal 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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
90
space/components/issues/filters/applied-filters/root.tsx
Normal file
90
space/components/issues/filters/applied-filters/root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
39
space/components/issues/filters/applied-filters/state.tsx
Normal file
39
space/components/issues/filters/applied-filters/state.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
72
space/components/issues/filters/helpers/dropdown.tsx
Normal file
72
space/components/issues/filters/helpers/dropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
22
space/components/issues/filters/helpers/filter-header.tsx
Normal file
22
space/components/issues/filters/helpers/filter-header.tsx
Normal 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>
|
||||||
|
);
|
35
space/components/issues/filters/helpers/filter-option.tsx
Normal file
35
space/components/issues/filters/helpers/filter-option.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
3
space/components/issues/filters/helpers/index.ts
Normal file
3
space/components/issues/filters/helpers/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./dropdown";
|
||||||
|
export * from "./filter-header";
|
||||||
|
export * from "./filter-option";
|
11
space/components/issues/filters/index.ts
Normal file
11
space/components/issues/filters/index.ts
Normal 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";
|
83
space/components/issues/filters/labels.tsx
Normal file
83
space/components/issues/filters/labels.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
51
space/components/issues/filters/priority.tsx
Normal file
51
space/components/issues/filters/priority.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
77
space/components/issues/filters/root.tsx
Normal file
77
space/components/issues/filters/root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
86
space/components/issues/filters/selection.tsx
Normal file
86
space/components/issues/filters/selection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
78
space/components/issues/filters/state.tsx
Normal file
78
space/components/issues/filters/state.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<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 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>
|
||||||
)}
|
)}
|
||||||
|
@ -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}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
@ -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";
|
||||||
|
|
||||||
|
@ -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 = {
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
|
@ -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 = {
|
||||||
|
@ -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";
|
|
||||||
|
@ -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;
|
|
@ -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 };
|
|
@ -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>
|
|
||||||
);
|
|
@ -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>
|
|
||||||
);
|
|
@ -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 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
// mobx
|
// mobx
|
||||||
|
@ -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
|
||||||
|
29
space/store/issues/base-issue-filter.store.ts
Normal file
29
space/store/issues/base-issue-filter.store.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
52
space/store/issues/helpers.ts
Normal file
52
space/store/issues/helpers.ts
Normal 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;
|
||||||
|
};
|
106
space/store/issues/issue-filters.store.ts
Normal file
106
space/store/issues/issue-filters.store.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
36
space/store/issues/types.ts
Normal file
36
space/store/issues/types.ts
Normal 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;
|
||||||
|
}
|
@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -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}
|
||||||
>
|
>
|
||||||
|
@ -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) => (
|
||||||
|
@ -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) => (
|
||||||
|
@ -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={() => {
|
||||||
|
@ -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 = () => {
|
||||||
@ -304,10 +283,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
cycleDetails.total_issues === 0
|
cycleDetails.total_issues === 0
|
||||||
? "0 Issue"
|
? "0 Issue"
|
||||||
: cycleDetails.total_issues === cycleDetails.completed_issues
|
: cycleDetails.total_issues === cycleDetails.completed_issues
|
||||||
? cycleDetails.total_issues > 1
|
? cycleDetails.total_issues > 1
|
||||||
? `${cycleDetails.total_issues}`
|
? `${cycleDetails.total_issues}`
|
||||||
: `${cycleDetails.total_issues}`
|
: `${cycleDetails.total_issues}`
|
||||||
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
|
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -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
|
||||||
setTrackElement("CYCLE_PAGE_SIDEBAR");
|
onClick={() => {
|
||||||
setCycleDeleteModal(true)
|
setTrackElement("CYCLE_PAGE_SIDEBAR");
|
||||||
}
|
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>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -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);
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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";
|
||||||
|
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
@ -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}
|
||||||
|
@ -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="text-xs line-clamp-1 text-custom-text-300">
|
<div className="relative">
|
||||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
<div className="text-xs line-clamp-1 text-custom-text-300">
|
||||||
|
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||||
|
</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>
|
</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);
|
||||||
|
@ -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 (
|
||||||
<>
|
<>
|
||||||
<CreateUpdateIssueModal
|
{isDraftIssue ? (
|
||||||
isOpen={isOpen}
|
<CreateUpdateDraftIssueModal
|
||||||
handleClose={() => setIsOpen(false)}
|
isOpen={isOpen}
|
||||||
prePopulateData={issuePayload}
|
handleClose={() => setIsOpen(false)}
|
||||||
currentStore={currentStore}
|
prePopulateData={issuePayload}
|
||||||
/>
|
fieldsToShow={["all"]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CreateUpdateIssueModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
handleClose={() => setIsOpen(false)}
|
||||||
|
prePopulateData={issuePayload}
|
||||||
|
currentStore={currentStore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{renderExistingIssueModal && (
|
{renderExistingIssueModal && (
|
||||||
<ExistingIssuesListModal
|
<ExistingIssuesListModal
|
||||||
isOpen={openExistingIssueListModal}
|
isOpen={openExistingIssueListModal}
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<CreateUpdateIssueModal
|
{isDraftIssue ? (
|
||||||
isOpen={isOpen}
|
<CreateUpdateDraftIssueModal
|
||||||
handleClose={() => setIsOpen(false)}
|
isOpen={isOpen}
|
||||||
currentStore={currentStore}
|
handleClose={() => setIsOpen(false)}
|
||||||
prePopulateData={issuePayload}
|
prePopulateData={issuePayload}
|
||||||
/>
|
fieldsToShow={["all"]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CreateUpdateIssueModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
handleClose={() => setIsOpen(false)}
|
||||||
|
currentStore={currentStore}
|
||||||
|
prePopulateData={issuePayload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{renderExistingIssueModal && (
|
{renderExistingIssueModal && (
|
||||||
<ExistingIssuesListModal
|
<ExistingIssuesListModal
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user