Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac353f35c6 | ||
|
|
9b2c66afbe | ||
|
|
4b738e1c8a | ||
|
|
1af42a7819 |
@@ -51,7 +51,8 @@ const defaultFromData: TFormData = {
|
||||
is_telemetry_enabled: true,
|
||||
};
|
||||
|
||||
export function InstanceSetupForm() {
|
||||
export function InstanceSetupForm(props) {
|
||||
const {} = props;
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
const firstNameParam = searchParams.get("first_name") || undefined;
|
||||
|
||||
@@ -364,19 +364,6 @@ class LabelSerializer(BaseSerializer):
|
||||
]
|
||||
read_only_fields = ["workspace", "project"]
|
||||
|
||||
def validate_name(self, value):
|
||||
project_id = self.context.get("project_id")
|
||||
|
||||
label = Label.objects.filter(project_id=project_id, name__iexact=value)
|
||||
|
||||
if self.instance:
|
||||
label = label.exclude(id=self.instance.pk)
|
||||
|
||||
if label.exists():
|
||||
raise serializers.ValidationError(detail="LABEL_NAME_ALREADY_EXISTS")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class LabelLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -14,6 +14,7 @@ from plane.app.views import (
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
UserProjectRolesEndpoint,
|
||||
ProjectArchiveUnarchiveEndpoint,
|
||||
ProjectMemberPreferenceEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -125,4 +126,9 @@ urlpatterns = [
|
||||
ProjectArchiveUnarchiveEndpoint.as_view(),
|
||||
name="project-archive-unarchive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/preferences/member/<uuid:member_id>/",
|
||||
ProjectMemberPreferenceEndpoint.as_view(),
|
||||
name="project-member-preference",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -30,16 +30,6 @@ urlpatterns = [
|
||||
UserEndpoint.as_view({"get": "retrieve_user_settings"}),
|
||||
name="users",
|
||||
),
|
||||
path(
|
||||
"users/me/email/generate-code/",
|
||||
UserEndpoint.as_view({"post": "generate_email_verification_code"}),
|
||||
name="user-email-verify-code",
|
||||
),
|
||||
path(
|
||||
"users/me/email/",
|
||||
UserEndpoint.as_view({"patch": "update_email"}),
|
||||
name="user-email-update",
|
||||
),
|
||||
# Profile
|
||||
path("users/me/profile/", ProfileEndpoint.as_view(), name="accounts"),
|
||||
# End profile
|
||||
|
||||
@@ -253,9 +253,4 @@ urlpatterns = [
|
||||
WorkspaceUserPreferenceViewSet.as_view(),
|
||||
name="workspace-user-preference",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/sidebar-preferences/<str:key>/",
|
||||
WorkspaceUserPreferenceViewSet.as_view(),
|
||||
name="workspace-user-preference",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -18,6 +18,7 @@ from .project.member import (
|
||||
ProjectMemberViewSet,
|
||||
ProjectMemberUserEndpoint,
|
||||
UserProjectRolesEndpoint,
|
||||
ProjectMemberPreferenceEndpoint,
|
||||
)
|
||||
|
||||
from .user.base import (
|
||||
|
||||
@@ -39,9 +39,7 @@ class LabelViewSet(BaseViewSet):
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
serializer = LabelSerializer(
|
||||
data=request.data, context={"project_id": project_id}
|
||||
)
|
||||
serializer = LabelSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
@@ -66,18 +64,8 @@ class LabelViewSet(BaseViewSet):
|
||||
{"error": "Label with the same name already exists in the project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = LabelSerializer(
|
||||
instance=self.get_object(),
|
||||
data=request.data,
|
||||
context={"project_id": kwargs["project_id"]},
|
||||
partial=True,
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
# call the parent method to perform the update
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
@@ -89,7 +77,6 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def post(self, request, slug, project_id):
|
||||
label_data = request.data.get("label_data", [])
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
labels = Label.objects.bulk_create(
|
||||
|
||||
@@ -300,3 +300,37 @@ class UserProjectRolesEndpoint(BaseAPIView):
|
||||
|
||||
project_members = {str(member["project_id"]): member["role"] for member in project_members}
|
||||
return Response(project_members, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ProjectMemberPreferenceEndpoint(BaseAPIView):
|
||||
def get_project_member(self, slug, project_id, member_id):
|
||||
return ProjectMember.objects.get(
|
||||
project_id=project_id,
|
||||
member_id=member_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def patch(self, request, slug, project_id, member_id):
|
||||
project_member = self.get_project_member(slug, project_id, member_id)
|
||||
|
||||
current_preferences = project_member.preferences or {}
|
||||
current_preferences["navigation"] = request.data["navigation"]
|
||||
|
||||
project_member.preferences = current_preferences
|
||||
project_member.save(update_fields=["preferences"])
|
||||
|
||||
return Response({"preferences": project_member.preferences}, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, member_id):
|
||||
project_member = self.get_project_member(slug, project_id, member_id)
|
||||
|
||||
response = {
|
||||
"preferences": project_member.preferences,
|
||||
"project_id": project_member.project_id,
|
||||
"member_id": project_member.member_id,
|
||||
"workspace_id": project_member.workspace_id,
|
||||
}
|
||||
|
||||
return Response(response, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import secrets
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Case, Count, IntegerField, Q, When
|
||||
from django.contrib.auth import logout
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django.views.decorators.vary import vary_on_cookie
|
||||
from django.core.validators import validate_email
|
||||
from django.core.cache import cache
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -46,11 +36,9 @@ from plane.utils.paginator import BasePaginator
|
||||
from plane.authentication.utils.host import user_ip
|
||||
from plane.bgtasks.user_deactivation_email_task import user_deactivation_email
|
||||
from plane.utils.host import base_host
|
||||
from plane.bgtasks.user_email_update_task import send_email_update_magic_code, send_email_update_confirmation
|
||||
from plane.authentication.rate_limit import EmailVerificationThrottle
|
||||
|
||||
|
||||
logger = logging.getLogger("plane")
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django.views.decorators.vary import vary_on_cookie
|
||||
|
||||
|
||||
class UserEndpoint(BaseViewSet):
|
||||
@@ -61,14 +49,6 @@ class UserEndpoint(BaseViewSet):
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
def get_throttles(self):
|
||||
"""
|
||||
Apply rate limiting to specific endpoints.
|
||||
"""
|
||||
if self.action == "generate_email_verification_code":
|
||||
return [EmailVerificationThrottle()]
|
||||
return super().get_throttles()
|
||||
|
||||
@method_decorator(cache_control(private=True, max_age=12))
|
||||
@method_decorator(vary_on_cookie)
|
||||
def retrieve(self, request):
|
||||
@@ -89,169 +69,6 @@ class UserEndpoint(BaseViewSet):
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
def _validate_new_email(self, user, new_email):
|
||||
"""
|
||||
Validate the new email address.
|
||||
|
||||
Args:
|
||||
user: The User instance
|
||||
new_email: The new email address to validate
|
||||
|
||||
Returns:
|
||||
Response object with error if validation fails, None if validation passes
|
||||
"""
|
||||
if not new_email:
|
||||
return Response(
|
||||
{"error": "Email is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate email format
|
||||
try:
|
||||
validate_email(new_email)
|
||||
except Exception:
|
||||
return Response(
|
||||
{"error": "Invalid email format"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if email is the same as current email
|
||||
if new_email == user.email:
|
||||
return Response(
|
||||
{"error": "New email must be different from current email"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if email already exists in the User model
|
||||
if User.objects.filter(email=new_email).exclude(id=user.id).exists():
|
||||
return Response(
|
||||
{"error": "An account with this email already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def generate_email_verification_code(self, request):
|
||||
"""
|
||||
Generate and send a magic code to the new email address for verification.
|
||||
Rate limited to 3 requests per hour per user (enforced by EmailVerificationThrottle).
|
||||
Additional per-email cooldown of 60 seconds prevents rapid repeated requests.
|
||||
"""
|
||||
user = self.get_object()
|
||||
new_email = request.data.get("email", "").strip().lower()
|
||||
|
||||
# Validate the new email
|
||||
validation_error = self._validate_new_email(user, new_email)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
try:
|
||||
# Generate magic code for email verification
|
||||
# Use a special key prefix to distinguish from regular magic signin
|
||||
# Include user ID to bind the code to the specific user
|
||||
cache_key = f"magic_email_update_{user.id}_{new_email}"
|
||||
## Generate a random token
|
||||
token = (
|
||||
"".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
+ "-"
|
||||
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
+ "-"
|
||||
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
)
|
||||
# Store in cache with 10 minute expiration
|
||||
cache_data = json.dumps({"token": token})
|
||||
cache.set(cache_key, cache_data, timeout=600)
|
||||
|
||||
# Send magic code to the new email
|
||||
send_email_update_magic_code.delay(new_email, token)
|
||||
|
||||
return Response(
|
||||
{"message": "Verification code sent to email"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to generate verification code: %s", str(e), exc_info=True)
|
||||
return Response(
|
||||
{"error": "Failed to generate verification code. Please try again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def update_email(self, request):
|
||||
"""
|
||||
Verify the magic code and update the user's email address.
|
||||
This endpoint verifies the code and updates the existing user record
|
||||
without creating a new user, ensuring the user ID remains unchanged.
|
||||
"""
|
||||
user = self.get_object()
|
||||
new_email = request.data.get("email", "").strip().lower()
|
||||
code = request.data.get("code", "").strip()
|
||||
|
||||
# Validate the new email
|
||||
validation_error = self._validate_new_email(user, new_email)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
if not code:
|
||||
return Response(
|
||||
{"error": "Verification code is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Verify the magic code
|
||||
try:
|
||||
cache_key = f"magic_email_update_{user.id}_{new_email}"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if not cached_data:
|
||||
logger.warning("Cache key not found: %s. Code may have expired or was never generated.", cache_key)
|
||||
return Response(
|
||||
{"error": "Verification code has expired or is invalid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
data = json.loads(cached_data)
|
||||
stored_token = data.get("token")
|
||||
|
||||
if str(stored_token) != str(code):
|
||||
return Response(
|
||||
{"error": "Invalid verification code"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": "Failed to verify code. Please try again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Final check: ensure email is still available (might have been taken between code generation and update)
|
||||
if User.objects.filter(email=new_email).exclude(id=user.id).exists():
|
||||
return Response(
|
||||
{"error": "An account with this email already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
old_email = user.email
|
||||
# Update the email - this updates the existing user record without creating a new user
|
||||
user.email = new_email
|
||||
# Reset email verification status when email is changed
|
||||
user.is_email_verified = False
|
||||
user.save()
|
||||
|
||||
# delete the cache
|
||||
cache.delete(cache_key)
|
||||
|
||||
# Logout the user
|
||||
logout(request)
|
||||
|
||||
# Send confirmation email to the new email address
|
||||
send_email_update_confirmation.delay(new_email)
|
||||
# send the email to the old email address
|
||||
send_email_update_confirmation.delay(old_email)
|
||||
|
||||
# Return updated user data
|
||||
serialized_data = UserMeSerializer(user).data
|
||||
return Response(serialized_data, status=status.HTTP_200_OK)
|
||||
|
||||
def deactivate(self, request):
|
||||
# Check all workspace user is active
|
||||
user = self.get_object()
|
||||
|
||||
@@ -65,15 +65,23 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def patch(self, request, slug, key):
|
||||
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug, user=request.user).first()
|
||||
def patch(self, request, slug):
|
||||
for data in request.data:
|
||||
key = data.pop("key", None)
|
||||
if not key:
|
||||
continue
|
||||
|
||||
if preference:
|
||||
serializer = WorkspaceUserPreferenceSerializer(preference, data=request.data, partial=True)
|
||||
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug).first()
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not preference:
|
||||
continue
|
||||
|
||||
return Response({"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if "is_pinned" in data:
|
||||
preference.is_pinned = data["is_pinned"]
|
||||
|
||||
if "sort_order" in data:
|
||||
preference.sort_order = data["sort_order"]
|
||||
|
||||
preference.save(update_fields=["is_pinned", "sort_order"])
|
||||
|
||||
return Response({"message": "Successfully updated"}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Third party imports
|
||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
@@ -22,22 +22,3 @@ class AuthenticationThrottle(AnonRateThrottle):
|
||||
)
|
||||
except AuthenticationException as e:
|
||||
return Response(e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
|
||||
|
||||
class EmailVerificationThrottle(UserRateThrottle):
|
||||
"""
|
||||
Throttle for email verification code generation.
|
||||
Limits to 3 requests per hour per user to prevent abuse.
|
||||
"""
|
||||
|
||||
rate = "3/hour"
|
||||
scope = "email_verification"
|
||||
|
||||
def throttle_failure_view(self, request, *args, **kwargs):
|
||||
try:
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
|
||||
error_message="RATE_LIMIT_EXCEEDED",
|
||||
)
|
||||
except AuthenticationException as e:
|
||||
return Response(e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_email_update_magic_code(email, token):
|
||||
try:
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
# Send the mail
|
||||
subject = "Verify your new email address"
|
||||
context = {"code": token, "email": email}
|
||||
|
||||
html_content = render_to_string("emails/auth/magic_signin.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info("Email sent successfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_email_update_confirmation(email):
|
||||
"""
|
||||
Send a confirmation email to the user after their email address has been successfully updated.
|
||||
|
||||
Args:
|
||||
email: The new email address that was successfully updated
|
||||
"""
|
||||
try:
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
# Send the confirmation email
|
||||
subject = "Plane email address successfully updated"
|
||||
context = {"email": email}
|
||||
|
||||
html_content = render_to_string("emails/user/email_updated.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info(f"Email update confirmation sent successfully to {email}.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -1,49 +0,0 @@
|
||||
# Django imports
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Description
|
||||
from plane.db.models import IssueComment
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create Description records for existing IssueComment"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
batch_size = 500
|
||||
|
||||
while True:
|
||||
comments = list(
|
||||
IssueComment.objects.filter(description_id__isnull=True).order_by("created_at")[:batch_size]
|
||||
)
|
||||
|
||||
if not comments:
|
||||
break
|
||||
|
||||
with transaction.atomic():
|
||||
descriptions = [
|
||||
Description(
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
description_json=comment.comment_json,
|
||||
description_html=comment.comment_html,
|
||||
description_stripped=comment.comment_stripped,
|
||||
project_id=comment.project_id,
|
||||
created_by_id=comment.created_by_id,
|
||||
updated_by_id=comment.updated_by_id,
|
||||
workspace_id=comment.workspace_id,
|
||||
)
|
||||
for comment in comments
|
||||
]
|
||||
|
||||
created_descriptions = Description.objects.bulk_create(descriptions)
|
||||
|
||||
comments_to_update = []
|
||||
for comment, description in zip(comments, created_descriptions):
|
||||
comment.description_id = description.id
|
||||
comments_to_update.append(comment)
|
||||
|
||||
IssueComment.objects.bulk_update(comments_to_update, ["description_id"])
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully Copied IssueComment to Description"))
|
||||
@@ -1,24 +0,0 @@
|
||||
# Generated by Django 4.2.22 on 2025-11-06 08:28
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0108_alter_issueactivity_issue_comment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='issuecomment',
|
||||
name='description',
|
||||
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='issue_comment_description', to='db.description'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuecomment',
|
||||
name='parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_issue_comment', to='db.issuecomment'),
|
||||
),
|
||||
]
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 4.2.26 on 2025-11-21 11:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0109_issuecomment_description_and_parent_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='workspaceuserproperties',
|
||||
name='navigation_control_preference',
|
||||
field=models.CharField(choices=[('ACCORDION', 'Accordion'), ('TABBED', 'Tabbed')], default='ACCORDION', max_length=25),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspaceuserproperties',
|
||||
name='navigation_project_limit',
|
||||
field=models.IntegerField(default=10),
|
||||
),
|
||||
]
|
||||
@@ -1,39 +0,0 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-29 15:36
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('db', '0110_workspaceuserproperties_navigation_control_preference_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['receiver', 'workspace', 'read_at', 'created_at'], name='notif_receiver_status_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['receiver', 'workspace', 'entity_name', 'read_at'], name='notif_receiver_entity_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['receiver', 'workspace', 'snoozed_till', 'archived_at'], name='notif_receiver_state_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['receiver', 'workspace', 'sender'], name='notif_receiver_sender_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='notification',
|
||||
index=models.Index(fields=['workspace', 'entity_identifier', 'entity_name'], name='notif_entity_lookup_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='fileasset',
|
||||
index=models.Index(fields=['asset'], name='asset_asset_idx'),
|
||||
),
|
||||
]
|
||||
@@ -66,7 +66,6 @@ class FileAsset(BaseModel):
|
||||
models.Index(fields=["entity_type"], name="asset_entity_type_idx"),
|
||||
models.Index(fields=["entity_identifier"], name="asset_entity_identifier_idx"),
|
||||
models.Index(fields=["entity_type", "entity_identifier"], name="asset_entity_idx"),
|
||||
models.Index(fields=["asset"], name="asset_asset_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -17,8 +17,6 @@ from plane.db.mixins import SoftDeletionManager
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from .project import ProjectBaseModel
|
||||
from plane.utils.uuid import convert_uuid_to_integer
|
||||
from .description import Description
|
||||
from plane.db.mixins import ChangeTrackerMixin
|
||||
|
||||
|
||||
def get_default_properties():
|
||||
@@ -444,13 +442,10 @@ class IssueActivity(ProjectBaseModel):
|
||||
return str(self.issue)
|
||||
|
||||
|
||||
class IssueComment(ChangeTrackerMixin, ProjectBaseModel):
|
||||
class IssueComment(ProjectBaseModel):
|
||||
comment_stripped = models.TextField(verbose_name="Comment", blank=True)
|
||||
comment_json = models.JSONField(blank=True, default=dict)
|
||||
comment_html = models.TextField(blank=True, default="<p></p>")
|
||||
description = models.OneToOneField(
|
||||
"db.Description", on_delete=models.CASCADE, related_name="issue_comment_description", null=True
|
||||
)
|
||||
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
|
||||
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_comments")
|
||||
# System can also create comment
|
||||
@@ -468,60 +463,10 @@ class IssueComment(ChangeTrackerMixin, ProjectBaseModel):
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
edited_at = models.DateTimeField(null=True, blank=True)
|
||||
parent = models.ForeignKey(
|
||||
"self", on_delete=models.CASCADE, null=True, blank=True, related_name="parent_issue_comment"
|
||||
)
|
||||
|
||||
TRACKED_FIELDS = ["comment_stripped", "comment_json", "comment_html"]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Custom save method for IssueComment that manages the associated Description model.
|
||||
|
||||
This method handles creation and updates of both the comment and its description in a
|
||||
single atomic transaction to ensure data consistency.
|
||||
"""
|
||||
|
||||
self.comment_stripped = strip_tags(self.comment_html) if self.comment_html != "" else ""
|
||||
is_creating = self._state.adding
|
||||
|
||||
# Prepare description defaults
|
||||
description_defaults = {
|
||||
"workspace_id": self.workspace_id,
|
||||
"project_id": self.project_id,
|
||||
"created_by_id": self.created_by_id,
|
||||
"updated_by_id": self.updated_by_id,
|
||||
"description_stripped": self.comment_stripped,
|
||||
"description_json": self.comment_json,
|
||||
"description_html": self.comment_html,
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
super(IssueComment, self).save(*args, **kwargs)
|
||||
|
||||
if is_creating or not self.description_id:
|
||||
# Create new description for new comment
|
||||
description = Description.objects.create(**description_defaults)
|
||||
self.description_id = description.id
|
||||
super(IssueComment, self).save(update_fields=["description_id"])
|
||||
else:
|
||||
field_mapping = {
|
||||
"comment_html": "description_html",
|
||||
"comment_stripped": "description_stripped",
|
||||
"comment_json": "description_json",
|
||||
}
|
||||
|
||||
changed_fields = {
|
||||
desc_field: getattr(self, comment_field)
|
||||
for comment_field, desc_field in field_mapping.items()
|
||||
if self.has_changed(comment_field)
|
||||
}
|
||||
|
||||
# Update description only if comment fields changed
|
||||
if changed_fields and self.description_id:
|
||||
Description.objects.filter(pk=self.description_id).update(
|
||||
**changed_fields, updated_by_id=self.updated_by_id, updated_at=self.updated_at
|
||||
)
|
||||
return super(IssueComment, self).save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Comment"
|
||||
|
||||
@@ -38,26 +38,6 @@ class Notification(BaseModel):
|
||||
models.Index(fields=["entity_name"], name="notif_entity_name_idx"),
|
||||
models.Index(fields=["read_at"], name="notif_read_at_idx"),
|
||||
models.Index(fields=["receiver", "read_at"], name="notif_entity_idx"),
|
||||
models.Index(
|
||||
fields=["receiver", "workspace", "read_at", "created_at"],
|
||||
name="notif_receiver_status_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["receiver", "workspace", "entity_name", "read_at"],
|
||||
name="notif_receiver_entity_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["receiver", "workspace", "snoozed_till", "archived_at"],
|
||||
name="notif_receiver_state_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["receiver", "workspace", "sender"],
|
||||
name="notif_receiver_sender_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["workspace", "entity_identifier", "entity_name"],
|
||||
name="notif_entity_lookup_idx",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -59,7 +59,7 @@ def get_default_props():
|
||||
|
||||
|
||||
def get_default_preferences():
|
||||
return {"pages": {"block_display": True}}
|
||||
return {"pages": {"block_display": True}, "navigation": {"default_tab": "work_items", "hide_in_more_menu": []}}
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
|
||||
@@ -301,10 +301,6 @@ class WorkspaceTheme(BaseModel):
|
||||
|
||||
|
||||
class WorkspaceUserProperties(BaseModel):
|
||||
class NavigationControlPreference(models.TextChoices):
|
||||
ACCORDION = "ACCORDION", "Accordion"
|
||||
TABBED = "TABBED", "Tabbed"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
@@ -319,12 +315,6 @@ class WorkspaceUserProperties(BaseModel):
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
navigation_project_limit = models.IntegerField(default=10)
|
||||
navigation_control_preference = models.CharField(
|
||||
max_length=25,
|
||||
choices=NavigationControlPreference.choices,
|
||||
default=NavigationControlPreference.ACCORDION,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "user", "deleted_at"]
|
||||
@@ -417,6 +407,7 @@ class WorkspaceUserPreference(BaseModel):
|
||||
DRAFTS = "drafts", "Drafts"
|
||||
YOUR_WORK = "your_work", "Your Work"
|
||||
ARCHIVES = "archives", "Archives"
|
||||
STICKIES = "stickies", "Stickies"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from plane.db.models import IssueComment, Description, Project, Issue, Workspace, State
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(create_user):
|
||||
"""Create a test workspace"""
|
||||
return Workspace.objects.create(
|
||||
name="Test Workspace",
|
||||
slug="test-workspace",
|
||||
owner=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(workspace, create_user):
|
||||
"""Create a test project"""
|
||||
return Project.objects.create(
|
||||
name="Test Project",
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(project):
|
||||
"""Create a test state"""
|
||||
return State.objects.create(
|
||||
name="Todo",
|
||||
project=project,
|
||||
group="backlog",
|
||||
default=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def issue(workspace, project, state, create_user):
|
||||
"""Create a test issue"""
|
||||
return Issue.objects.create(
|
||||
name="Test Issue",
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
state=state,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssueCommentModel:
|
||||
"""Test the IssueComment model"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_creation_creates_description(self, workspace, project, issue, create_user):
|
||||
"""Test that creating a comment automatically creates a description"""
|
||||
# Arrange
|
||||
comment_html = "<p>This is a test comment</p>"
|
||||
comment_json = {"type": "doc", "content": [{"type": "paragraph", "text": "This is a test comment"}]}
|
||||
|
||||
# Act
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=comment_html,
|
||||
comment_json=comment_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue_comment.id is not None
|
||||
assert issue_comment.comment_stripped == "This is a test comment"
|
||||
assert issue_comment.description_id is not None
|
||||
|
||||
# Verify description was created
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
assert description is not None
|
||||
assert description.description_html == comment_html
|
||||
assert description.description_json == comment_json
|
||||
assert description.description_stripped == "This is a test comment"
|
||||
assert description.workspace_id == workspace.id
|
||||
assert description.project_id == project.id
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_update_updates_description(self, workspace, project, issue, create_user):
|
||||
"""Test that updating a comment updates its associated description"""
|
||||
# Arrange - Create initial comment
|
||||
initial_html = "<p>Initial comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Initial comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
initial_description_id = issue_comment.description_id
|
||||
|
||||
# Act - Update the comment
|
||||
updated_html = "<p>Updated comment</p>"
|
||||
updated_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Updated comment"}]}
|
||||
|
||||
issue_comment.comment_html = updated_html
|
||||
issue_comment.comment_json = updated_json
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
# Refresh from database
|
||||
issue_comment.refresh_from_db()
|
||||
updated_description = Description.objects.get(pk=initial_description_id)
|
||||
|
||||
# Verify comment was updated
|
||||
assert issue_comment.comment_stripped == "Updated comment"
|
||||
assert issue_comment.description_id == initial_description_id # Same description object
|
||||
|
||||
# Verify description was updated
|
||||
assert updated_description.description_html == updated_html
|
||||
assert updated_description.description_json == updated_json
|
||||
assert updated_description.description_stripped == "Updated comment"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_update_only_changed_fields_in_description(self, workspace, project, issue, create_user):
|
||||
"""Test that only changed fields are updated in description"""
|
||||
# Arrange - Create initial comment
|
||||
initial_html = "<p>Initial comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Initial comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
initial_description_id = issue_comment.description_id
|
||||
|
||||
# Act - Update only the HTML (not JSON)
|
||||
updated_html = "<p>Updated comment only HTML</p>"
|
||||
|
||||
issue_comment.comment_html = updated_html
|
||||
# comment_json remains the same
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
updated_description = Description.objects.get(pk=initial_description_id)
|
||||
|
||||
# Verify HTML was updated
|
||||
assert updated_description.description_html == updated_html
|
||||
assert updated_description.description_stripped == "Updated comment only HTML"
|
||||
|
||||
# Verify JSON remained the same
|
||||
assert updated_description.description_json == initial_json
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_no_update_when_content_unchanged(self, workspace, project, issue, create_user):
|
||||
"""Test that description is not updated when comment content doesn't change"""
|
||||
# Arrange - Create initial comment
|
||||
initial_html = "<p>Test comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Test comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
initial_description_id = issue_comment.description_id
|
||||
|
||||
# Act - Save without changing content
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
updated_description = Description.objects.get(pk=initial_description_id)
|
||||
|
||||
# Verify description was not updated (updated_at should be the same)
|
||||
# Note: This test assumes updated_at is not changed when no fields change
|
||||
assert updated_description.description_html == initial_html
|
||||
assert updated_description.description_json == initial_json
|
||||
assert updated_description.description_stripped == "Test comment"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_update_creates_description_if_missing(self, workspace, project, issue, create_user):
|
||||
"""Test that updating a comment creates description if it doesn't exist (legacy data)"""
|
||||
# Arrange - Create comment and manually remove description (simulating legacy data)
|
||||
initial_html = "<p>Legacy comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Legacy comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Simulate legacy data by removing the description
|
||||
if issue_comment.description_id:
|
||||
Description.objects.filter(pk=issue_comment.description_id).delete()
|
||||
IssueComment.objects.filter(pk=issue_comment.pk).update(description_id=None)
|
||||
issue_comment.refresh_from_db()
|
||||
|
||||
assert issue_comment.description_id is None
|
||||
|
||||
# Act - Update the comment
|
||||
updated_html = "<p>Updated legacy comment</p>"
|
||||
updated_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Updated legacy comment"}]}
|
||||
|
||||
issue_comment.comment_html = updated_html
|
||||
issue_comment.comment_json = updated_json
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
issue_comment.refresh_from_db()
|
||||
|
||||
# Verify description was created
|
||||
assert issue_comment.description_id is not None
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
assert description.description_html == updated_html
|
||||
assert description.description_json == updated_json
|
||||
assert description.description_stripped == "Updated legacy comment"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_strips_html_tags(self, workspace, project, issue, create_user):
|
||||
"""Test that HTML tags are properly stripped from comment_html"""
|
||||
# Arrange
|
||||
comment_html = "<p>This is <strong>bold</strong> and <em>italic</em> text</p>"
|
||||
comment_json = {"type": "doc", "content": []}
|
||||
|
||||
# Act
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=comment_html,
|
||||
comment_json=comment_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue_comment.comment_stripped == "This is bold and italic text"
|
||||
|
||||
# Verify description has the same stripped content
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
assert description.description_stripped == "This is bold and italic text"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_empty_html_creates_empty_stripped(self, workspace, project, issue, create_user):
|
||||
"""Test that empty HTML results in empty comment_stripped"""
|
||||
# Arrange
|
||||
comment_html = ""
|
||||
comment_json = {"type": "doc", "content": []}
|
||||
|
||||
# Act
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=comment_html,
|
||||
comment_json=comment_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue_comment.comment_stripped == ""
|
||||
|
||||
# Verify description was created with empty stripped content
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
|
||||
assert description.description_stripped is None
|
||||
@@ -1,43 +0,0 @@
|
||||
import pytest
|
||||
from plane.app.serializers import LabelSerializer
|
||||
from plane.db.models import Project, Label
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLabelSerializer:
|
||||
"""Test the LabelSerializer"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_label_serializer_create_valid_data(self, db, workspace):
|
||||
"""Test creating a label with valid data"""
|
||||
project = Project.objects.create(
|
||||
name="Test Project", identifier="TEST", workspace=workspace
|
||||
)
|
||||
|
||||
serializer = LabelSerializer(
|
||||
data={"name": "Test Label"},
|
||||
context={"project_id": project.id},
|
||||
)
|
||||
assert serializer.is_valid()
|
||||
assert serializer.errors == {}
|
||||
serializer.save(project_id=project.id)
|
||||
|
||||
label = Label.objects.all().first()
|
||||
assert label.name == "Test Label"
|
||||
assert label.project == project
|
||||
assert label
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_label_serializer_create_duplicate_name(self, db, workspace):
|
||||
"""Test creating a label with a duplicate name"""
|
||||
project = Project.objects.create(
|
||||
name="Test Project", identifier="TEST", workspace=workspace
|
||||
)
|
||||
|
||||
Label.objects.create(name="Test Label", project=project)
|
||||
|
||||
serializer = LabelSerializer(
|
||||
data={"name": "Test Label"}, context={"project_id": project.id}
|
||||
)
|
||||
assert not serializer.is_valid()
|
||||
assert serializer.errors == {"name": ["LABEL_NAME_ALREADY_EXISTS"]}
|
||||
@@ -1,16 +1,10 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.http import QueryDict
|
||||
|
||||
# Third party imports
|
||||
from django_filters.utils import translate_validation
|
||||
from rest_framework import filters
|
||||
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
@@ -41,12 +35,12 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
|
||||
normalized = self._normalize_filter_data(filter_string, "filter")
|
||||
return self._apply_json_filter(queryset, normalized, view)
|
||||
except DRFValidationError:
|
||||
except ValidationError:
|
||||
# Propagate validation errors unchanged
|
||||
raise
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
raise
|
||||
# Convert unexpected errors to ValidationError to keep response consistent
|
||||
raise ValidationError(f"Filter error: {str(e)}")
|
||||
|
||||
def _normalize_filter_data(self, raw_filter, source_label):
|
||||
"""Return a dict from raw filter input or raise a ValidationError.
|
||||
@@ -59,19 +53,9 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
return json.loads(raw_filter)
|
||||
if isinstance(raw_filter, dict):
|
||||
return raw_filter
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": f"'{source_label}' must be a dict or a JSON string.",
|
||||
"code": "invalid_filter_type",
|
||||
}
|
||||
)
|
||||
raise ValidationError(f"'{source_label}' must be a dict or a JSON string.")
|
||||
except json.JSONDecodeError:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": (f"Invalid JSON for '{source_label}'. Expected a valid JSON object."),
|
||||
"code": "invalid_json",
|
||||
}
|
||||
)
|
||||
raise ValidationError(f"Invalid JSON for '{source_label}'. Expected a valid JSON object.")
|
||||
|
||||
def _apply_json_filter(self, queryset, filter_data, view):
|
||||
"""Process a JSON filter structure using Q object composition."""
|
||||
@@ -99,12 +83,7 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
allowed_fields = set(filterset_class.base_filters.keys()) if filterset_class else None
|
||||
if not allowed_fields:
|
||||
# If no FilterSet is configured, reject filtering to avoid unintended exposure # noqa: E501
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": ("Filtering is not enabled for this endpoint (missing filterset_class)"),
|
||||
"code": "filtering_not_enabled",
|
||||
}
|
||||
)
|
||||
raise ValidationError("Filtering is not enabled for this endpoint (missing filterset_class)")
|
||||
|
||||
# Extract field names from the filter data
|
||||
fields = self._extract_field_names(filter_data)
|
||||
@@ -115,25 +94,7 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
# Example: 'sequence_id__gte' should be declared in base_filters
|
||||
# Special-case __range: require the '<base>__range' filter itself
|
||||
if field not in allowed_fields:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": f"Filtering on field '{field}' is not allowed",
|
||||
"code": "invalid_filter_field",
|
||||
}
|
||||
)
|
||||
|
||||
def _transform_field_name_for_validation(self, field_name):
|
||||
"""Hook: Transform a field name before validation.
|
||||
|
||||
Override this in subclasses to handle special field naming conventions.
|
||||
|
||||
Args:
|
||||
field_name: The original field name from the filter data
|
||||
|
||||
Returns:
|
||||
The transformed field name to validate against the FilterSet
|
||||
"""
|
||||
return field_name
|
||||
raise ValidationError(f"Filtering on field '{field}' is not allowed")
|
||||
|
||||
def _extract_field_names(self, filter_data):
|
||||
"""Extract all field names from a nested filter structure"""
|
||||
@@ -151,9 +112,8 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
for item in value:
|
||||
fields.extend(self._extract_field_names(item))
|
||||
else:
|
||||
# This is a field name - apply transformation hook
|
||||
transformed_field = self._transform_field_name_for_validation(key)
|
||||
fields.append(transformed_field)
|
||||
# This is a field name
|
||||
fields.append(key)
|
||||
return fields
|
||||
return []
|
||||
|
||||
@@ -211,23 +171,6 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
# Leaf dict: evaluate via FilterSet to get a Q object
|
||||
return self._build_leaf_q(node, view, queryset)
|
||||
|
||||
def _preprocess_leaf_conditions(self, leaf_conditions, view, queryset):
|
||||
"""Hook: Preprocess leaf conditions before building Q object.
|
||||
|
||||
Override this in subclasses to transform filter keys/values.
|
||||
For example, custom property filters might need to be transformed
|
||||
from 'customproperty_<id>__<lookup>' to 'customproperty_value__<lookup>'.
|
||||
|
||||
Args:
|
||||
leaf_conditions: Dict of field filters
|
||||
view: The view instance
|
||||
queryset: The queryset being filtered
|
||||
|
||||
Returns:
|
||||
Dict of transformed field filters
|
||||
"""
|
||||
return leaf_conditions
|
||||
|
||||
def _build_leaf_q(self, leaf_conditions, view, queryset):
|
||||
"""Build a Q object from leaf filter conditions using the view's FilterSet.
|
||||
|
||||
@@ -243,19 +186,11 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
# Get the filterset class from the view
|
||||
filterset_class = getattr(view, "filterset_class", None)
|
||||
if not filterset_class:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": ("Filtering requires a filterset_class to be defined on the view"),
|
||||
"code": "filterset_missing",
|
||||
}
|
||||
)
|
||||
|
||||
# Apply preprocessing hook
|
||||
processed_conditions = self._preprocess_leaf_conditions(leaf_conditions, view, queryset)
|
||||
raise ValidationError("Filtering requires a filterset_class to be defined on the view")
|
||||
|
||||
# Build a QueryDict from the leaf conditions
|
||||
qd = QueryDict(mutable=True)
|
||||
for key, value in processed_conditions.items():
|
||||
for key, value in leaf_conditions.items():
|
||||
# Default serialization to string; QueryDict expects strings
|
||||
if isinstance(value, list):
|
||||
# Repeat key for list values (e.g., __in)
|
||||
@@ -271,23 +206,11 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
fs = filterset_class(data=qd, queryset=queryset)
|
||||
|
||||
if not fs.is_valid():
|
||||
ve = translate_validation(fs.errors)
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": "Invalid filter parameters",
|
||||
"code": "invalid_filterset",
|
||||
"errors": ve.detail,
|
||||
}
|
||||
)
|
||||
raise translate_validation(fs.errors)
|
||||
|
||||
# Build and return the combined Q object
|
||||
if not hasattr(fs, "build_combined_q"):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": ("FilterSet must have build_combined_q method for complex filtering"),
|
||||
"code": "missing_build_combined_q",
|
||||
}
|
||||
)
|
||||
raise ValidationError("FilterSet must have build_combined_q method for complex filtering")
|
||||
|
||||
return fs.build_combined_q()
|
||||
|
||||
@@ -320,69 +243,34 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
- Depth must not exceed max_depth
|
||||
"""
|
||||
if current_depth > max_depth:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": (f"Filter nesting is too deep (max {max_depth}); found depth {current_depth}"),
|
||||
"code": "max_depth_exceeded",
|
||||
}
|
||||
)
|
||||
raise ValidationError(f"Filter nesting is too deep (max {max_depth}); found depth {current_depth}")
|
||||
|
||||
if not isinstance(node, dict):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": "Each filter node must be a JSON object",
|
||||
"code": "invalid_filter_node",
|
||||
}
|
||||
)
|
||||
raise ValidationError("Each filter node must be a JSON object")
|
||||
|
||||
if not node:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": "Filter objects must not be empty",
|
||||
"code": "empty_filter_object",
|
||||
}
|
||||
)
|
||||
raise ValidationError("Filter objects must not be empty")
|
||||
|
||||
logical_keys = [k for k in node.keys() if isinstance(k, str) and k.lower() in ("or", "and", "not")]
|
||||
|
||||
if len(logical_keys) > 1:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": ("A filter object cannot contain multiple logical operators at the same level"),
|
||||
"code": "multiple_logical_operators",
|
||||
}
|
||||
)
|
||||
raise ValidationError("A filter object cannot contain multiple logical operators at the same level")
|
||||
|
||||
if len(logical_keys) == 1:
|
||||
op_key = logical_keys[0]
|
||||
# must not mix operator with other keys
|
||||
if len(node) != 1:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": (f"Cannot mix logical operator '{op_key}' with field keys at the same level"),
|
||||
"code": "mixed_operator_and_fields",
|
||||
}
|
||||
)
|
||||
raise ValidationError(f"Cannot mix logical operator '{op_key}' with field keys at the same level")
|
||||
|
||||
op = op_key.lower()
|
||||
value = node[op_key]
|
||||
|
||||
if op in ("or", "and"):
|
||||
if not isinstance(value, list) or len(value) == 0:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": f"'{op}' must be a non-empty list of filter objects",
|
||||
"code": "invalid_operator_children",
|
||||
}
|
||||
)
|
||||
raise ValidationError(f"'{op}' must be a non-empty list of filter objects")
|
||||
for child in value:
|
||||
if not isinstance(child, dict):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": f"All children of '{op}' must be JSON objects",
|
||||
"code": "invalid_operator_child_type",
|
||||
}
|
||||
)
|
||||
raise ValidationError(f"All children of '{op}' must be JSON objects")
|
||||
self._validate_structure(
|
||||
child,
|
||||
max_depth=max_depth,
|
||||
@@ -392,12 +280,7 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
|
||||
if op == "not":
|
||||
if not isinstance(value, dict):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": "'not' must be a single JSON object",
|
||||
"code": "invalid_not_child",
|
||||
}
|
||||
)
|
||||
raise ValidationError("'not' must be a single JSON object")
|
||||
self._validate_structure(value, max_depth=max_depth, current_depth=current_depth + 1)
|
||||
return
|
||||
|
||||
@@ -407,49 +290,24 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
def _validate_leaf(self, leaf):
|
||||
"""Validate a leaf dict containing field lookups and values."""
|
||||
if not isinstance(leaf, dict) or not leaf:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": "Leaf filter must be a non-empty JSON object",
|
||||
"code": "invalid_leaf",
|
||||
}
|
||||
)
|
||||
raise ValidationError("Leaf filter must be a non-empty JSON object")
|
||||
|
||||
for key, value in leaf.items():
|
||||
if isinstance(key, str) and key.lower() in ("or", "and", "not"):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": "Logical operators cannot appear in a leaf filter object",
|
||||
"code": "operator_in_leaf",
|
||||
}
|
||||
)
|
||||
raise ValidationError("Logical operators cannot appear in a leaf filter object")
|
||||
|
||||
# Lists/Tuples must contain only scalar values
|
||||
if isinstance(value, (list, tuple)):
|
||||
if len(value) == 0:
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": f"List value for '{key}' must not be empty",
|
||||
"code": "empty_list_value",
|
||||
}
|
||||
)
|
||||
raise ValidationError(f"List value for '{key}' must not be empty")
|
||||
for item in value:
|
||||
if not self._is_scalar(item):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": f"List value for '{key}' must contain only scalar items",
|
||||
"code": "non_scalar_list_item",
|
||||
}
|
||||
)
|
||||
raise ValidationError(f"List value for '{key}' must contain only scalar items")
|
||||
continue
|
||||
|
||||
# Scalars and None are allowed
|
||||
if not self._is_scalar(value):
|
||||
raise DRFValidationError(
|
||||
{
|
||||
"message": (f"Value for '{key}' must be a scalar, null, or list/tuple of scalars"),
|
||||
"code": "invalid_value_type",
|
||||
}
|
||||
)
|
||||
raise ValidationError(f"Value for '{key}' must be a scalar, null, or list/tuple of scalars")
|
||||
|
||||
def _is_scalar(self, value):
|
||||
return value is None or isinstance(value, (str, int, float, bool))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,10 +56,9 @@ COPY --from=installer /app/packages ./packages
|
||||
COPY --from=installer /app/apps/live/dist ./apps/live/dist
|
||||
COPY --from=installer /app/apps/live/node_modules ./apps/live/node_modules
|
||||
COPY --from=installer /app/node_modules ./node_modules
|
||||
COPY --from=installer /app/apps/live/package.json ./apps/live/package.json
|
||||
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "apps/live"]
|
||||
CMD ["node", "apps/live/dist/start.js"]
|
||||
|
||||
@@ -3,18 +3,13 @@
|
||||
"version": "1.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "A realtime collaborative server powers Plane's rich text editor",
|
||||
"main": "./dist/start.mjs",
|
||||
"module": "./dist/start.mjs",
|
||||
"exports": {
|
||||
".": "./dist/start.mjs",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"main": "./dist/start.js",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit && tsdown",
|
||||
"dev": "tsdown --watch --onSuccess \"node --env-file=.env .\"",
|
||||
"start": "node --env-file=.env .",
|
||||
"dev": "tsdown --watch --onSuccess \"node --env-file=.env dist/start.js\"",
|
||||
"start": "node --env-file=.env dist/start.js",
|
||||
"check:lint": "eslint . --max-warnings 10",
|
||||
"check:types": "tsc --noEmit",
|
||||
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"extends": "@plane/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ES2015",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2015"],
|
||||
"target": "ES2015",
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"baseUrl": ".",
|
||||
|
||||
@@ -7,5 +7,4 @@ export default defineConfig({
|
||||
dts: false,
|
||||
clean: true,
|
||||
sourcemap: false,
|
||||
exports: true,
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./mentions";
|
||||
@@ -1,8 +1,6 @@
|
||||
// plane editor
|
||||
import type { TCallbackMentionComponentProps } from "@plane/editor";
|
||||
|
||||
export type TEditorMentionComponentProps = TCallbackMentionComponentProps;
|
||||
|
||||
export function EditorAdditionalMentionsRoot(_props: TEditorMentionComponentProps) {
|
||||
export function EditorAdditionalMentionsRoot(_props: TCallbackMentionComponentProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./embeds";
|
||||
@@ -1,10 +1,11 @@
|
||||
// plane web imports
|
||||
import type { TEditorMentionComponentProps } from "@/plane-web/components/editor/embeds/mentions";
|
||||
import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor/embeds/mentions";
|
||||
// plane editor
|
||||
import type { TCallbackMentionComponentProps } from "@plane/editor";
|
||||
// plane web components
|
||||
import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor";
|
||||
// local components
|
||||
import { EditorUserMention } from "./user";
|
||||
|
||||
export function EditorMentionsRoot(props: TEditorMentionComponentProps) {
|
||||
export function EditorMentionsRoot(props: TCallbackMentionComponentProps) {
|
||||
const { entity_identifier, entity_name } = props;
|
||||
|
||||
switch (entity_name) {
|
||||
|
||||
@@ -104,3 +104,5 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
KanbanIssueBlock.displayName = "KanbanIssueBlock";
|
||||
|
||||
@@ -1,47 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane ui
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TStateGroups } from "@plane/types";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
//hooks
|
||||
import { useStates } from "@/hooks/store/use-state";
|
||||
|
||||
type Props = {
|
||||
stateId: string | undefined;
|
||||
shouldShowBorder?: boolean;
|
||||
} & (
|
||||
| {
|
||||
stateDetails: {
|
||||
name: string;
|
||||
group: TStateGroups;
|
||||
};
|
||||
}
|
||||
| {
|
||||
stateId: string;
|
||||
}
|
||||
);
|
||||
|
||||
export const IssueBlockState: React.FC<Props> = observer((props) => {
|
||||
const { shouldShowBorder = true } = props;
|
||||
// store hooks
|
||||
};
|
||||
export const IssueBlockState = observer(function IssueBlockState({ stateId, shouldShowBorder = true }: Props) {
|
||||
const { getStateById } = useStates();
|
||||
// derived values
|
||||
const state = "stateId" in props ? getStateById(props.stateId) : props.stateDetails;
|
||||
if (!state) return null;
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
return (
|
||||
<Tooltip tooltipHeading="State" tooltipContent={state.name}>
|
||||
<Tooltip tooltipHeading="State" tooltipContent={state?.name ?? "State"}>
|
||||
<div
|
||||
className={cn("flex h-full w-full items-center justify-between gap-1 rounded px-2.5 py-1 text-xs", {
|
||||
"border-[0.5px] border-custom-border-300": shouldShowBorder,
|
||||
})}
|
||||
>
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<StateGroupIcon stateGroup={state.group} />
|
||||
<div className="text-xs">{state.name}</div>
|
||||
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} />
|
||||
<div className="text-xs">{state?.name ?? "State"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
// plane package imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmptyStateDetailed } from "@plane/propel/empty-state";
|
||||
import { Tabs } from "@plane/propel/tabs";
|
||||
import { Tabs } from "@plane/ui";
|
||||
import type { TabItem } from "@plane/ui";
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
// hooks
|
||||
@@ -18,7 +16,7 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAnalyticsTabs } from "@/plane-web/components/analytics/use-analytics-tabs";
|
||||
import { getAnalyticsTabs } from "@/plane-web/components/analytics/tabs";
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
function AnalyticsPage({ params }: Route.ComponentProps) {
|
||||
@@ -36,32 +34,33 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
|
||||
: undefined;
|
||||
|
||||
// permissions
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
const workspaceSlug = params.workspaceSlug;
|
||||
const ANALYTICS_TABS = useAnalyticsTabs(workspaceSlug.toString());
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState(tabId || ANALYTICS_TABS[0]?.key);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabId) {
|
||||
setSelectedTab(tabId);
|
||||
}
|
||||
}, [tabId]);
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = (value: string) => {
|
||||
setSelectedTab(value);
|
||||
router.push(`/${currentWorkspace?.slug}/analytics/${value}`);
|
||||
};
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
|
||||
: undefined;
|
||||
const ANALYTICS_TABS = useMemo(function ANALYTICS_TABS() {
|
||||
return getAnalyticsTabs(t);
|
||||
});
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() =>
|
||||
ANALYTICS_TABS.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
content: <tab.content />,
|
||||
onClick: () => {
|
||||
router.push(`/${currentWorkspace?.slug}/analytics/${tab.key}`);
|
||||
},
|
||||
disabled: tab.isDisabled,
|
||||
})),
|
||||
[ANALYTICS_TABS, router, currentWorkspace?.slug]
|
||||
);
|
||||
const defaultTab = tabId;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,43 +68,19 @@ function AnalyticsPage({ params }: Route.ComponentProps) {
|
||||
{workspaceProjectIds && (
|
||||
<>
|
||||
{workspaceProjectIds.length > 0 || loader === "init-loader" ? (
|
||||
<div className="flex h-full overflow-hidden bg-custom-background-100 ">
|
||||
<Tabs value={selectedTab} onValueChange={handleTabChange} className="w-full h-full">
|
||||
<div className={"flex flex-col w-full h-full"}>
|
||||
<div
|
||||
className={cn(
|
||||
"px-6 py-2 border-b border-custom-border-200 flex items-center gap-4 overflow-hidden w-full justify-between"
|
||||
)}
|
||||
>
|
||||
<Tabs.List className={"my-2 overflow-x-auto flex w-fit"}>
|
||||
{ANALYTICS_TABS.map((tab) => (
|
||||
<Tabs.Trigger
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
disabled={tab.isDisabled}
|
||||
size="md"
|
||||
className="px-3"
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<AnalyticsFilterActions />
|
||||
</div>
|
||||
</div>
|
||||
{ANALYTICS_TABS.map((tab) => (
|
||||
<Tabs.Content
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className={"h-full overflow-hidden overflow-y-auto px-2"}
|
||||
>
|
||||
<tab.content />
|
||||
</Tabs.Content>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
<div className="flex h-full overflow-hidden bg-custom-background-100 justify-between items-center ">
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
storageKey={`analytics-page-${currentWorkspace?.id}`}
|
||||
defaultTab={defaultTab}
|
||||
size="md"
|
||||
tabListContainerClassName="px-6 py-2 border-b border-custom-border-200 flex items-center justify-between"
|
||||
tabListClassName="my-2 w-auto"
|
||||
tabClassName="px-3"
|
||||
tabPanelClassName="h-full overflow-hidden overflow-y-auto px-2"
|
||||
storeInLocalStorage={false}
|
||||
actions={<AnalyticsFilterActions />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStateDetailed
|
||||
|
||||
@@ -7,13 +7,13 @@ import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
|
||||
export default function WorkspaceLayout() {
|
||||
return (
|
||||
<AuthenticationWrapper>
|
||||
<AppRailProvider>
|
||||
<WorkspaceAuthWrapper>
|
||||
<WorkspaceAuthWrapper>
|
||||
<AppRailProvider>
|
||||
<WorkspaceContentWrapper>
|
||||
<Outlet />
|
||||
</WorkspaceContentWrapper>
|
||||
</WorkspaceAuthWrapper>
|
||||
</AppRailProvider>
|
||||
</AppRailProvider>
|
||||
</WorkspaceAuthWrapper>
|
||||
</AuthenticationWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { getAnalyticsTabs } from "./tabs";
|
||||
|
||||
export const useAnalyticsTabs = (workspaceSlug: string) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const analyticsTabs = useMemo(() => getAnalyticsTabs(t), [t]);
|
||||
|
||||
return analyticsTabs;
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { ICycle } from "@plane/types";
|
||||
import { Row } from "@plane/ui";
|
||||
// assets
|
||||
import darkActiveCycleAsset from "@/app/assets/empty-state/cycle/active-dark.webp?url";
|
||||
@@ -27,69 +27,6 @@ interface IActiveCycleDetails {
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
type ActiveCyclesComponentProps = {
|
||||
cycleId: string | null | undefined;
|
||||
activeCycle: ICycle | null;
|
||||
activeCycleResolvedPath: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
handleFiltersUpdate: (filters: any) => void;
|
||||
cycleIssueDetails?: ActiveCycleIssueDetails | { nextPageResults: boolean };
|
||||
};
|
||||
|
||||
const ActiveCyclesComponent = observer(function ActiveCyclesComponent({
|
||||
cycleId,
|
||||
activeCycle,
|
||||
activeCycleResolvedPath,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
handleFiltersUpdate,
|
||||
cycleIssueDetails,
|
||||
}: ActiveCyclesComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!cycleId || !activeCycle) {
|
||||
return (
|
||||
<DetailedEmptyState
|
||||
title={t("project_cycles.empty_state.active.title")}
|
||||
description={t("project_cycles.empty_state.active.description")}
|
||||
assetPath={activeCycleResolvedPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-b border-custom-border-200">
|
||||
<CyclesListItem
|
||||
key={cycleId}
|
||||
cycleId={cycleId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
className="!border-b-transparent"
|
||||
/>
|
||||
<Row className="bg-custom-background-100 pt-3 pb-6">
|
||||
<div className="grid grid-cols-1 bg-custom-background-100 gap-3 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<ActiveCycleProgress
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
cycle={activeCycle}
|
||||
/>
|
||||
<ActiveCycleProductivity workspaceSlug={workspaceSlug} projectId={projectId} cycle={activeCycle} />
|
||||
<ActiveCycleStats
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
cycle={activeCycle}
|
||||
cycleId={cycleId}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
cycleIssueDetails={cycleIssueDetails}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: IActiveCycleDetails) {
|
||||
const { workspaceSlug, projectId, cycleId: propsCycleId, showHeader = true } = props;
|
||||
// theme hook
|
||||
@@ -108,6 +45,51 @@ export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: IActiveC
|
||||
cycleIssueDetails,
|
||||
} = useCyclesDetails({ workspaceSlug, projectId, cycleId });
|
||||
|
||||
const ActiveCyclesComponent = useMemo(function ActiveCyclesComponent() {
|
||||
return (
|
||||
<>
|
||||
{!cycleId || !activeCycle ? (
|
||||
<DetailedEmptyState
|
||||
title={t("project_cycles.empty_state.active.title")}
|
||||
description={t("project_cycles.empty_state.active.description")}
|
||||
assetPath={activeCycleResolvedPath}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col border-b border-custom-border-200">
|
||||
{cycleId && (
|
||||
<CyclesListItem
|
||||
key={cycleId}
|
||||
cycleId={cycleId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
className="!border-b-transparent"
|
||||
/>
|
||||
)}
|
||||
<Row className="bg-custom-background-100 pt-3 pb-6">
|
||||
<div className="grid grid-cols-1 bg-custom-background-100 gap-3 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<ActiveCycleProgress
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
cycle={activeCycle}
|
||||
/>
|
||||
<ActiveCycleProductivity workspaceSlug={workspaceSlug} projectId={projectId} cycle={activeCycle} />
|
||||
<ActiveCycleStats
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
cycle={activeCycle}
|
||||
cycleId={cycleId}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
cycleIssueDetails={cycleIssueDetails as ActiveCycleIssueDetails}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{showHeader ? (
|
||||
@@ -117,30 +99,12 @@ export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: IActiveC
|
||||
<Disclosure.Button className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 cursor-pointer">
|
||||
<CycleListGroupHeader title={t("project_cycles.active_cycle.label")} type="current" isExpanded={open} />
|
||||
</Disclosure.Button>
|
||||
<Disclosure.Panel>
|
||||
<ActiveCyclesComponent
|
||||
cycleId={cycleId}
|
||||
activeCycle={activeCycle}
|
||||
activeCycleResolvedPath={activeCycleResolvedPath}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
cycleIssueDetails={cycleIssueDetails}
|
||||
/>
|
||||
</Disclosure.Panel>
|
||||
<Disclosure.Panel>{ActiveCyclesComponent}</Disclosure.Panel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
) : (
|
||||
<ActiveCyclesComponent
|
||||
cycleId={cycleId}
|
||||
activeCycle={activeCycle}
|
||||
activeCycleResolvedPath={activeCycleResolvedPath}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
cycleIssueDetails={cycleIssueDetails}
|
||||
/>
|
||||
<>{ActiveCyclesComponent}</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,10 +4,11 @@ import { observer } from "mobx-react";
|
||||
import { useAnalytics } from "@/hooks/store/use-analytics";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
// components
|
||||
import DurationDropdown from "./select/duration";
|
||||
import { ProjectSelect } from "./select/project";
|
||||
|
||||
const AnalyticsFilterActions = observer(function AnalyticsFilterActions() {
|
||||
const { selectedProjects, updateSelectedProjects } = useAnalytics();
|
||||
const { selectedProjects, selectedDuration, updateSelectedProjects, updateSelectedDuration } = useAnalytics();
|
||||
const { joinedProjectIds } = useProject();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// plane package imports
|
||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import { ProjectIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// plane web hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
@@ -34,9 +33,9 @@ function ActiveProjectItem(props: Props) {
|
||||
if (!projectDetails) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="flex items-center gap-2 flex-1 overflow-hidden">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-custom-background-80 shrink-0">
|
||||
<div className="flex items-center justify-between gap-2 ">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-custom-background-80">
|
||||
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
|
||||
{projectDetails?.logo_props ? (
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
@@ -47,15 +46,13 @@ function ActiveProjectItem(props: Props) {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip tooltipContent={projectDetails?.name} position="top-start">
|
||||
<p className="text-sm font-medium truncate">{projectDetails?.name}</p>
|
||||
</Tooltip>
|
||||
<p className="text-sm font-medium">{projectDetails?.name}</p>
|
||||
</div>
|
||||
<CompletionPercentage
|
||||
percentage={completed_issues && total_issues ? Math.round((completed_issues / total_issues) * 100) : 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default ActiveProjectItem;
|
||||
|
||||
@@ -31,8 +31,8 @@ export const CommentQuickActions = observer(function CommentQuickActions(props:
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const MENU_ITEMS = useMemo<TContextMenuItem[]>(
|
||||
() => [
|
||||
const MENU_ITEMS = useMemo(function MENU_ITEMS() {
|
||||
return [
|
||||
{
|
||||
key: "edit",
|
||||
action: setEditMode,
|
||||
@@ -70,9 +70,8 @@ export const CommentQuickActions = observer(function CommentQuickActions(props:
|
||||
icon: Trash2,
|
||||
shouldRender: canDelete,
|
||||
},
|
||||
],
|
||||
[t, setEditMode, canEdit, showCopyLinkOption, activityOperations, comment, showAccessSpecifier, canDelete]
|
||||
);
|
||||
];
|
||||
});
|
||||
|
||||
return (
|
||||
<CustomMenu ellipsis closeOnSelect>
|
||||
|
||||
@@ -79,3 +79,5 @@ export const BreadcrumbLink = observer(function BreadcrumbLink(props: Props) {
|
||||
|
||||
return <ItemWrapper {...itemWrapperProps}>{content}</ItemWrapper>;
|
||||
});
|
||||
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Transition, Dialog } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Input } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { authErrorHandler } from "@/helpers/authentication.helper";
|
||||
import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
import userService from "@/services/user.service";
|
||||
|
||||
type Props = { isOpen: boolean; onClose: () => void };
|
||||
|
||||
type TModalStep = "EMAIL" | "UNIQUE_CODE";
|
||||
type TUniqueCodeValuesForm = { email: string; code: string };
|
||||
|
||||
const defaultValues: TUniqueCodeValuesForm = { email: "", code: "" };
|
||||
|
||||
// service initialization
|
||||
const authService = new AuthService();
|
||||
|
||||
export const ChangeEmailModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose } = props;
|
||||
// states
|
||||
const [currentStep, setCurrentStep] = useState<TModalStep>("EMAIL");
|
||||
// store hooks
|
||||
const { signOut } = useUser();
|
||||
const { t } = useTranslation();
|
||||
const changeEmailT = (path: string) => t(`account_settings.profile.change_email_modal.${path}`);
|
||||
// form info
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
setError,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TUniqueCodeValuesForm>({ defaultValues });
|
||||
|
||||
const secondStep = currentStep === "UNIQUE_CODE";
|
||||
|
||||
const handleClose = () => {
|
||||
reset({ ...defaultValues });
|
||||
setCurrentStep("EMAIL");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut().catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("sign_out.toast.error.title"),
|
||||
message: t("sign_out.toast.error.message"),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: TUniqueCodeValuesForm) => {
|
||||
if (currentStep === "UNIQUE_CODE") {
|
||||
// Step 2: Verify the code and update email
|
||||
try {
|
||||
await userService.verifyEmailCode({ email: formData.email, code: formData.code });
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: changeEmailT("toasts.success_title"),
|
||||
message: changeEmailT("toasts.success_message"),
|
||||
});
|
||||
|
||||
// Sign out the user after successful email update
|
||||
await handleSignOut();
|
||||
handleClose();
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
(error as { error?: string; message?: string })?.error ||
|
||||
(error as { error?: string; message?: string })?.message ||
|
||||
changeEmailT("form.code.errors.invalid");
|
||||
setError("code", { type: "custom", message: errorMessage });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Check email and generate verification code
|
||||
try {
|
||||
// Get CSRF token
|
||||
const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token);
|
||||
if (!csrfToken) throw new Error("CSRF token not found");
|
||||
|
||||
// Check if email is available
|
||||
const emailCheckResponse = await userService.checkEmail(csrfToken, formData.email);
|
||||
|
||||
// Check if email already exists
|
||||
if (emailCheckResponse?.existing === true) {
|
||||
setError("email", { type: "custom", message: changeEmailT("form.email.errors.exists") });
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate verification code and send to new email
|
||||
await userService.generateEmailCode({ email: formData.email });
|
||||
|
||||
// Move to verification code step
|
||||
setCurrentStep("UNIQUE_CODE");
|
||||
} catch (error: unknown) {
|
||||
// Extract error code and message from backend response
|
||||
const err = error as { error_code?: number | string; error_message?: string };
|
||||
const errorCode = err?.error_code?.toString();
|
||||
|
||||
// Use authErrorHandler to get user-friendly error message
|
||||
const errorInfo = errorCode ? authErrorHandler(errorCode as EAuthenticationErrorCodes) : undefined;
|
||||
|
||||
// Get error message from handler or fallback
|
||||
const errorMessage = errorInfo
|
||||
? typeof errorInfo.message === "string"
|
||||
? errorInfo.message
|
||||
: String(errorInfo.message)
|
||||
: err?.error_message || changeEmailT("form.email.errors.validation_failed");
|
||||
|
||||
setError("email", { type: "custom", message: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 transition-opacity bg-custom-backdrop" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="overflow-y-auto fixed inset-0 z-30">
|
||||
<div className="flex justify-center items-center p-4 min-h-full text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-4 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-[30rem]">
|
||||
<div className="py-4 space-y-0">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
{changeEmailT("title")}
|
||||
</Dialog.Title>
|
||||
<p className="my-4 text-sm text-custom-text-200">{changeEmailT("description")}</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="flex flex-col gap-1">
|
||||
{secondStep && (
|
||||
<h4 className="text-sm font-medium text-custom-text-200">{changeEmailT("form.email.label")}</h4>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: changeEmailT("form.email.errors.required"),
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: changeEmailT("form.email.errors.invalid"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.email)}
|
||||
placeholder={changeEmailT("form.email.placeholder")}
|
||||
className={cn(
|
||||
{ "border-red-500": errors.email },
|
||||
{ "cursor-not-allowed !bg-custom-background-90": secondStep }
|
||||
)}
|
||||
disabled={secondStep}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.email && <span className="text-xs text-red-500">{errors?.email?.message}</span>}
|
||||
</div>
|
||||
|
||||
{secondStep && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm font-medium text-custom-text-200">{changeEmailT("form.code.label")}</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="code"
|
||||
rules={{ required: changeEmailT("form.code.errors.required") }}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="code"
|
||||
name="code"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
placeholder={changeEmailT("form.code.placeholder")}
|
||||
className={cn({ "border-red-500": errors.code })}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.code ? (
|
||||
<span className="text-xs text-red-500">{errors?.code?.message}</span>
|
||||
) : (
|
||||
<span className="text-xs text-green-700">{changeEmailT("form.code.helper_text")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200 py-4">
|
||||
<Button type="button" variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{changeEmailT("actions.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="sm" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? changeEmailT("states.sending")
|
||||
: secondStep
|
||||
? changeEmailT("actions.confirm")
|
||||
: changeEmailT("actions.continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
@@ -21,3 +21,5 @@ export const MultipleSelectGroup = observer(function MultipleSelectGroup(props:
|
||||
|
||||
return <>{children(helpers)}</>;
|
||||
});
|
||||
|
||||
MultipleSelectGroup.displayName = "MultipleSelectGroup";
|
||||
|
||||
@@ -44,7 +44,7 @@ export type ActiveCycleStatsProps = {
|
||||
cycle: ICycle | null;
|
||||
cycleId?: string | null;
|
||||
handleFiltersUpdate: (conditions: TWorkItemFilterCondition[]) => void;
|
||||
cycleIssueDetails?: ActiveCycleIssueDetails | { nextPageResults: boolean };
|
||||
cycleIssueDetails: ActiveCycleIssueDetails;
|
||||
};
|
||||
|
||||
export const ActiveCycleStats = observer(function ActiveCycleStats(props: ActiveCycleStatsProps) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
export const useWorkItemCommentOperations = (
|
||||
export const useCommentOperations = (
|
||||
workspaceSlug: string | undefined,
|
||||
projectId: string | undefined,
|
||||
issueId: string | undefined
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { ActivityFilterRoot } from "@/plane-web/components/issues/worklog/activity/filter-root";
|
||||
import { IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog/activity/worklog-create-button";
|
||||
import { IssueActivityCommentRoot } from "./activity-comment-root";
|
||||
import { useWorkItemCommentOperations } from "./helper";
|
||||
import { useCommentOperations } from "./helper";
|
||||
import { ActivitySortRoot } from "./sort-root";
|
||||
|
||||
type TIssueActivity = {
|
||||
@@ -77,11 +77,11 @@ export const IssueActivity = observer(function IssueActivity(props: TIssueActivi
|
||||
};
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
setSortOrder(sortOrder || E_SORT_ORDER.ASC);
|
||||
setSortOrder(sortOrder === E_SORT_ORDER.ASC ? E_SORT_ORDER.DESC : E_SORT_ORDER.ASC);
|
||||
};
|
||||
|
||||
// helper hooks
|
||||
const activityOperations = useWorkItemCommentOperations(workspaceSlug, projectId, issueId);
|
||||
const activityOperations = useCommentOperations(workspaceSlug, projectId, issueId);
|
||||
|
||||
const project = getProjectById(projectId);
|
||||
const renderCommentCreationBox = useMemo(
|
||||
|
||||
@@ -32,7 +32,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const CalendarIssueBlock = observer(
|
||||
forwardRef(function CalendarIssueBlock(props: Props, ref: React.ForwardedRef<HTMLAnchorElement>) {
|
||||
forwardRef<HTMLAnchorElement, Props>((props, ref) => {
|
||||
const { issue, quickActions, isDragging = false, isEpic = false } = props;
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
|
||||
@@ -299,3 +299,5 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
KanbanIssueBlock.displayName = "KanbanIssueBlock";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
@@ -36,126 +36,6 @@ export interface IIssuePropertyLabels {
|
||||
fullHeight?: boolean;
|
||||
}
|
||||
|
||||
type NoLabelProps = {
|
||||
isMobile: boolean;
|
||||
noLabelBorder: boolean;
|
||||
fullWidth: boolean;
|
||||
placeholderText?: string;
|
||||
};
|
||||
|
||||
const NoLabel = observer(function NoLabel({ isMobile, noLabelBorder, fullWidth, placeholderText }: NoLabelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
position="top"
|
||||
tooltipHeading={t("common.labels")}
|
||||
tooltipContent="None"
|
||||
isMobile={isMobile}
|
||||
renderByDefault={false}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full items-center justify-center gap-2 rounded px-2.5 py-1 text-xs hover:bg-custom-background-80",
|
||||
noLabelBorder ? "rounded-none" : "border-[0.5px] border-custom-border-300",
|
||||
fullWidth && "w-full"
|
||||
)}
|
||||
>
|
||||
<LabelPropertyIcon className="h-3.5 w-3.5" />
|
||||
{placeholderText}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
type LabelSummaryProps = {
|
||||
isMobile: boolean;
|
||||
fullWidth: boolean;
|
||||
noLabelBorder: boolean;
|
||||
disabled?: boolean;
|
||||
projectLabels: IIssueLabel[];
|
||||
value: string[];
|
||||
};
|
||||
|
||||
function LabelSummary({ isMobile, fullWidth, noLabelBorder, disabled, projectLabels, value }: LabelSummaryProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 flex-shrink-0 items-center justify-center rounded px-2.5 text-xs",
|
||||
fullWidth && "w-full",
|
||||
noLabelBorder ? "rounded-none" : "border-[0.5px] border-custom-border-300",
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
position="top"
|
||||
tooltipHeading={t("common.labels")}
|
||||
tooltipContent={projectLabels
|
||||
?.filter((l) => value.includes(l?.id))
|
||||
.map((l) => l?.name)
|
||||
.join(", ")}
|
||||
renderByDefault={false}
|
||||
>
|
||||
<div className="flex h-full items-center gap-1.5 text-custom-text-200">
|
||||
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" />
|
||||
{`${value.length} Labels`}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type LabelItemProps = {
|
||||
label: IIssueLabel;
|
||||
isMobile: boolean;
|
||||
renderByDefault: boolean;
|
||||
disabled?: boolean;
|
||||
fullWidth: boolean;
|
||||
noLabelBorder: boolean;
|
||||
};
|
||||
|
||||
const LabelItem = observer(function LabelItem({
|
||||
label,
|
||||
isMobile,
|
||||
renderByDefault,
|
||||
disabled,
|
||||
fullWidth,
|
||||
noLabelBorder,
|
||||
}: LabelItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
position="top"
|
||||
tooltipHeading={t("common.labels")}
|
||||
tooltipContent={label?.name ?? ""}
|
||||
isMobile={isMobile}
|
||||
renderByDefault={renderByDefault}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex overflow-hidden justify-center hover:bg-custom-background-80 max-w-full h-full flex-shrink-0 items-center rounded px-2.5 text-xs",
|
||||
!disabled && "cursor-pointer",
|
||||
fullWidth && "w-full",
|
||||
noLabelBorder ? "rounded-none" : "border-[0.5px] border-custom-border-300"
|
||||
)}
|
||||
>
|
||||
<div className="flex max-w-full items-center gap-1.5 overflow-hidden text-custom-text-200">
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<div className="line-clamp-1 inline-block w-auto max-w-[200px] truncate">{label?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
export const IssuePropertyLabels = observer(function IssuePropertyLabels(props: IIssuePropertyLabels) {
|
||||
const {
|
||||
projectId,
|
||||
@@ -174,6 +54,8 @@ export const IssuePropertyLabels = observer(function IssuePropertyLabels(props:
|
||||
fullWidth = false,
|
||||
fullHeight = false,
|
||||
} = props;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// refs
|
||||
@@ -201,6 +83,91 @@ export const IssuePropertyLabels = observer(function IssuePropertyLabels(props:
|
||||
let projectLabels: IIssueLabel[] = defaultOptions as IIssueLabel[];
|
||||
if (storeLabels && storeLabels.length > 0) projectLabels = storeLabels;
|
||||
|
||||
const NoLabel = useMemo(function NoLabel() {
|
||||
return (
|
||||
<Tooltip
|
||||
position="top"
|
||||
tooltipHeading={t("common.labels")}
|
||||
tooltipContent="None"
|
||||
isMobile={isMobile}
|
||||
renderByDefault={false}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full items-center justify-center gap-2 rounded px-2.5 py-1 text-xs hover:bg-custom-background-80",
|
||||
noLabelBorder ? "rounded-none" : "border-[0.5px] border-custom-border-300",
|
||||
fullWidth && "w-full"
|
||||
)}
|
||||
>
|
||||
<LabelPropertyIcon className="h-3.5 w-3.5" />
|
||||
{placeholderText}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
const LabelSummary = useMemo(function LabelSummary() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 flex-shrink-0 items-center justify-center rounded px-2.5 text-xs",
|
||||
fullWidth && "w-full",
|
||||
noLabelBorder ? "rounded-none" : "border-[0.5px] border-custom-border-300",
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
position="top"
|
||||
tooltipHeading={t("common.labels")}
|
||||
tooltipContent={projectLabels
|
||||
?.filter((l) => value.includes(l?.id))
|
||||
.map((l) => l?.name)
|
||||
.join(", ")}
|
||||
renderByDefault={false}
|
||||
>
|
||||
<div className="flex h-full items-center gap-1.5 text-custom-text-200">
|
||||
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" />
|
||||
{`${value.length} Labels`}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const LabelItem = useCallback(function LabelItem({ label }: { label: IIssueLabel }) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={label.id}
|
||||
position="top"
|
||||
tooltipHeading={t("common.labels")}
|
||||
tooltipContent={label?.name ?? ""}
|
||||
isMobile={isMobile}
|
||||
renderByDefault={renderByDefault}
|
||||
>
|
||||
<div
|
||||
key={label?.id}
|
||||
className={cn(
|
||||
"flex overflow-hidden justify-center hover:bg-custom-background-80 max-w-full h-full flex-shrink-0 items-center rounded px-2.5 text-xs",
|
||||
!disabled && "cursor-pointer",
|
||||
fullWidth && "w-full",
|
||||
noLabelBorder ? "rounded-none" : "border-[0.5px] border-custom-border-300"
|
||||
)}
|
||||
>
|
||||
<div className="flex max-w-full items-center gap-1.5 overflow-hidden text-custom-text-200">
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<div className="line-clamp-1 inline-block w-auto max-w-[200px] truncate">{label?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{value.length > 0 ? (
|
||||
@@ -218,16 +185,7 @@ export const IssuePropertyLabels = observer(function IssuePropertyLabels(props:
|
||||
hideDropdownArrow={hideDropdownArrow}
|
||||
fullWidth={fullWidth}
|
||||
fullHeight={fullHeight}
|
||||
label={
|
||||
<LabelItem
|
||||
label={label}
|
||||
isMobile={isMobile}
|
||||
renderByDefault={renderByDefault}
|
||||
disabled={disabled}
|
||||
fullWidth={fullWidth}
|
||||
noLabelBorder={noLabelBorder}
|
||||
/>
|
||||
}
|
||||
label={<LabelItem label={label} />}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
@@ -240,16 +198,7 @@ export const IssuePropertyLabels = observer(function IssuePropertyLabels(props:
|
||||
placement={placement}
|
||||
fullWidth={fullWidth}
|
||||
fullHeight={fullHeight}
|
||||
label={
|
||||
<LabelSummary
|
||||
isMobile={isMobile}
|
||||
fullWidth={fullWidth}
|
||||
noLabelBorder={noLabelBorder}
|
||||
disabled={disabled}
|
||||
projectLabels={projectLabels}
|
||||
value={value}
|
||||
/>
|
||||
}
|
||||
label={LabelSummary}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
@@ -262,14 +211,7 @@ export const IssuePropertyLabels = observer(function IssuePropertyLabels(props:
|
||||
placement={placement}
|
||||
fullWidth={fullWidth}
|
||||
fullHeight={fullHeight}
|
||||
label={
|
||||
<NoLabel
|
||||
isMobile={isMobile}
|
||||
noLabelBorder={noLabelBorder}
|
||||
fullWidth={fullWidth}
|
||||
placeholderText={placeholderText}
|
||||
/>
|
||||
}
|
||||
label={NoLabel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -33,10 +33,7 @@ const defaultValues: Partial<IIssueLabel> = {
|
||||
};
|
||||
|
||||
export const CreateUpdateLabelInline = observer(
|
||||
forwardRef(function CreateUpdateLabelInline(
|
||||
props: TCreateUpdateLabelInlineProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
forwardRef<HTMLDivElement, TCreateUpdateLabelInlineProps>(function CreateUpdateLabelInline(props, ref) {
|
||||
const { labelForm, setLabelForm, isUpdating, labelOperationsCallbacks, labelToUpdate, onClose } = props;
|
||||
// form info
|
||||
const {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
// plane imports
|
||||
import type { IWorkspaceMemberInvitation } from "@plane/types";
|
||||
import { EOnboardingSteps } from "@plane/types";
|
||||
@@ -15,25 +16,8 @@ type Props = {
|
||||
handleStepChange: (step: EOnboardingSteps, skipInvites?: boolean) => void;
|
||||
};
|
||||
|
||||
function OnboardingStepContent({ currentStep, invitations, handleStepChange }: Props) {
|
||||
switch (currentStep) {
|
||||
case EOnboardingSteps.PROFILE_SETUP:
|
||||
return <ProfileSetupStep handleStepChange={handleStepChange} />;
|
||||
case EOnboardingSteps.ROLE_SETUP:
|
||||
return <RoleSetupStep handleStepChange={handleStepChange} />;
|
||||
case EOnboardingSteps.USE_CASE_SETUP:
|
||||
return <UseCaseSetupStep handleStepChange={handleStepChange} />;
|
||||
case EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN:
|
||||
return <WorkspaceSetupStep invitations={invitations ?? []} handleStepChange={handleStepChange} />;
|
||||
case EOnboardingSteps.INVITE_MEMBERS:
|
||||
return <InviteTeamStep handleStepChange={handleStepChange} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function OnboardingStepRoot(props: Props) {
|
||||
const { currentStep } = props;
|
||||
const { currentStep, invitations, handleStepChange } = props;
|
||||
// ref for the scrollable container
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -47,12 +31,24 @@ export function OnboardingStepRoot(props: Props) {
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// memoized step component mapping
|
||||
const stepComponents = useMemo(
|
||||
() => ({
|
||||
[EOnboardingSteps.PROFILE_SETUP]: <ProfileSetupStep handleStepChange={handleStepChange} />,
|
||||
[EOnboardingSteps.ROLE_SETUP]: <RoleSetupStep handleStepChange={handleStepChange} />,
|
||||
[EOnboardingSteps.USE_CASE_SETUP]: <UseCaseSetupStep handleStepChange={handleStepChange} />,
|
||||
[EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN]: (
|
||||
<WorkspaceSetupStep invitations={invitations ?? []} handleStepChange={handleStepChange} />
|
||||
),
|
||||
[EOnboardingSteps.INVITE_MEMBERS]: <InviteTeamStep handleStepChange={handleStepChange} />,
|
||||
}),
|
||||
[handleStepChange, invitations]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-full p-8">
|
||||
<div className="w-full max-w-[24rem]">
|
||||
<OnboardingStepContent {...props} />
|
||||
</div>
|
||||
<div className="w-full max-w-[24rem]">{stepComponents[currentStep]} </div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -87,7 +87,7 @@ export const PageActions = observer(function PageActions(props: Props) {
|
||||
canCurrentUserMovePage,
|
||||
} = page;
|
||||
// menu items
|
||||
const MENU_ITEMS = useMemo(() => {
|
||||
const MENU_ITEMS = useMemo(function MENU_ITEMS() {
|
||||
const menuItems: (TContextMenuItem & { key: TPageActions })[] = [
|
||||
{
|
||||
key: "toggle-lock",
|
||||
@@ -175,26 +175,13 @@ export const PageActions = observer(function PageActions(props: Props) {
|
||||
menuItems.push(...extraOptions);
|
||||
}
|
||||
return menuItems;
|
||||
}, [
|
||||
extraOptions,
|
||||
is_locked,
|
||||
canCurrentUserLockPage,
|
||||
access,
|
||||
canCurrentUserChangeAccess,
|
||||
archived_at,
|
||||
canCurrentUserDuplicatePage,
|
||||
canCurrentUserArchivePage,
|
||||
canCurrentUserDeletePage,
|
||||
canCurrentUserMovePage,
|
||||
isMovePageEnabled,
|
||||
pageOperations,
|
||||
]);
|
||||
});
|
||||
// arrange options
|
||||
const arrangedOptions = useMemo<(TContextMenuItem & { key: TPageActions })[]>(
|
||||
const arrangedOptions = useMemo(
|
||||
() =>
|
||||
optionsOrder
|
||||
.map((key) => MENU_ITEMS.find((item) => item.key === key))
|
||||
.filter((item): item is TContextMenuItem & { key: TPageActions } => !!item),
|
||||
.filter((item) => !!item) as (TContextMenuItem & { key: TPageActions })[],
|
||||
[optionsOrder, MENU_ITEMS]
|
||||
);
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props:
|
||||
// query params
|
||||
const { updateQueryParams } = useQueryParams();
|
||||
// menu items list
|
||||
const EXTRA_MENU_OPTIONS = useMemo<(TContextMenuItem & { key: TPageActions })[]>(
|
||||
() => [
|
||||
const EXTRA_MENU_OPTIONS = useMemo(function EXTRA_MENU_OPTIONS() {
|
||||
return [
|
||||
{
|
||||
key: "full-screen",
|
||||
action: () => handleFullWidth(!isFullWidth),
|
||||
@@ -106,19 +106,8 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props:
|
||||
icon: ArrowUpToLine,
|
||||
shouldRender: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
handleFullWidth,
|
||||
isFullWidth,
|
||||
handleStickyToolbar,
|
||||
isStickyToolbarEnabled,
|
||||
isContentEditable,
|
||||
editorRef,
|
||||
updateQueryParams,
|
||||
router,
|
||||
setIsExportModalOpen,
|
||||
]
|
||||
);
|
||||
];
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { range } from "lodash-es";
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export function PageLoader() {
|
||||
export function PageLoader(props) {
|
||||
const {} = props;
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full flex flex-col">
|
||||
<div className="px-3 border-b border-custom-border-100 py-3">
|
||||
|
||||
@@ -17,12 +17,10 @@ import { cn, getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { DeactivateAccountModal } from "@/components/account/deactivate-account-modal";
|
||||
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
|
||||
import { ChangeEmailModal } from "@/components/core/modals/change-email-modal";
|
||||
import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal";
|
||||
// helpers
|
||||
import { captureSuccess, captureError } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useUser, useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
type TUserProfileForm = {
|
||||
@@ -51,7 +49,6 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
|
||||
const [deactivateAccountModal, setDeactivateAccountModal] = useState(false);
|
||||
const [isChangeEmailModalOpen, setIsChangeEmailModalOpen] = useState(false);
|
||||
// language support
|
||||
const { t } = useTranslation();
|
||||
// form info
|
||||
@@ -81,9 +78,6 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
|
||||
// store hooks
|
||||
const { data: currentUser, updateCurrentUser } = useUser();
|
||||
const { updateUserProfile } = useUserProfile();
|
||||
const { config } = useInstance();
|
||||
|
||||
const isSMTPConfigured = config?.is_smtp_configured || false;
|
||||
|
||||
const handleProfilePictureDelete = async (url: string | null | undefined) => {
|
||||
if (!url) return;
|
||||
@@ -162,7 +156,6 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
|
||||
return (
|
||||
<>
|
||||
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(false)} />
|
||||
<ChangeEmailModal isOpen={isChangeEmailModalOpen} onClose={() => setIsChangeEmailModalOpen(false)} />
|
||||
<Controller
|
||||
control={control}
|
||||
name="avatar_url"
|
||||
@@ -362,15 +355,6 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isSMTPConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs underline btn w-fit text-custom-text-200"
|
||||
onClick={() => setIsChangeEmailModalOpen(true)}
|
||||
>
|
||||
{t("change_email")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { TwitterPicker } from "react-color";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { IState } from "@plane/types";
|
||||
@@ -12,17 +12,6 @@ type TStateForm = {
|
||||
buttonTitle: string;
|
||||
};
|
||||
|
||||
function PopoverButton({ color }: { color?: string }) {
|
||||
return (
|
||||
<div
|
||||
className="group inline-flex items-center text-base font-medium focus:outline-none h-5 w-5 rounded transition-all"
|
||||
style={{
|
||||
backgroundColor: color ?? "black",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function StateForm(props: TStateForm) {
|
||||
const { data, onSubmit, onCancel, buttonDisabled, buttonTitle } = props;
|
||||
// states
|
||||
@@ -56,11 +45,22 @@ export function StateForm(props: TStateForm) {
|
||||
}
|
||||
};
|
||||
|
||||
const PopoverButton = useMemo(function PopoverButton() {
|
||||
return (
|
||||
<div
|
||||
className="group inline-flex items-center text-base font-medium focus:outline-none h-5 w-5 rounded transition-all"
|
||||
style={{
|
||||
backgroundColor: formData?.color ?? "black",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex space-x-2 bg-custom-background-100 p-3 rounded">
|
||||
{/* color */}
|
||||
<div className="flex-shrink-0 h-full mt-2">
|
||||
<Popover button={<PopoverButton color={formData?.color} />} panelClassName="mt-4 -ml-3">
|
||||
<Popover button={PopoverButton} panelClassName="mt-4 -ml-3">
|
||||
<TwitterPicker color={formData?.color} onChange={(value) => handleFormData("color", value.hex)} />
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +119,7 @@ export type AppSidebarItemComponent = React.FC<AppSidebarItemProps> & {
|
||||
Button: React.FC<AppSidebarButtonItemProps>;
|
||||
};
|
||||
|
||||
function AppSidebarItem({ variant = "link", item }: AppSidebarItemProps) {
|
||||
function AppSidebarItem({ variant = "link", item }) {
|
||||
if (!item) return null;
|
||||
|
||||
const { icon, isActive, label, href, onClick, disabled } = item;
|
||||
|
||||
@@ -33,27 +33,27 @@ interface Props {
|
||||
options?: any;
|
||||
}
|
||||
|
||||
function HeadingPrimary({ children }: { children: React.ReactNode }) {
|
||||
function HeadingPrimary({ children }) {
|
||||
return <h1 className="text-lg font-semibold text-custom-text-100">{children}</h1>;
|
||||
}
|
||||
|
||||
function HeadingSecondary({ children }: { children: React.ReactNode }) {
|
||||
function HeadingSecondary({ children }) {
|
||||
return <h3 className="text-base font-semibold text-custom-text-100">{children}</h3>;
|
||||
}
|
||||
|
||||
function Paragraph({ children }: { children: React.ReactNode }) {
|
||||
function Paragraph({ children }) {
|
||||
return <p className="text-sm text-custom-text-200">{children}</p>;
|
||||
}
|
||||
|
||||
function OrderedList({ children }: { children: React.ReactNode }) {
|
||||
function OrderedList({ children }) {
|
||||
return <ol className="mb-4 ml-8 list-decimal text-sm text-custom-text-200">{children}</ol>;
|
||||
}
|
||||
|
||||
function UnorderedList({ children }: { children: React.ReactNode }) {
|
||||
function UnorderedList({ children }) {
|
||||
return <ul className="mb-4 ml-8 list-disc text-sm text-custom-text-200">{children}</ul>;
|
||||
}
|
||||
|
||||
function Link({ href, children }: CustomComponentProps) {
|
||||
function Link({ href, children }) {
|
||||
return (
|
||||
<a href={href} className="underline hover:no-underline" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
|
||||
@@ -60,51 +60,51 @@ export const useRealtimePageEvents = ({
|
||||
[getUserDetails]
|
||||
);
|
||||
|
||||
const ACTION_HANDLERS = useMemo(
|
||||
() => ({
|
||||
archived: ({ pageIds, data }: { pageIds: string[]; data: EventToPayloadMap["archived"] }) => {
|
||||
const ACTION_HANDLERS = useMemo(function ACTION_HANDLERS() {
|
||||
return {
|
||||
archived: ({ pageIds, data }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.archive({ archived_at: data.archived_at, shouldSync: false });
|
||||
});
|
||||
},
|
||||
|
||||
unarchived: ({ pageIds }: { pageIds: string[] }) => {
|
||||
unarchived: ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.restore({ shouldSync: false });
|
||||
});
|
||||
},
|
||||
|
||||
locked: ({ pageIds }: { pageIds: string[] }) => {
|
||||
locked: ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.lock({ shouldSync: false, recursive: false });
|
||||
});
|
||||
},
|
||||
|
||||
unlocked: ({ pageIds }: { pageIds: string[] }) => {
|
||||
unlocked: ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.unlock({ shouldSync: false, recursive: false });
|
||||
});
|
||||
},
|
||||
|
||||
"made-public": ({ pageIds }: { pageIds: string[] }) => {
|
||||
"made-public": ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.makePublic({ shouldSync: false });
|
||||
});
|
||||
},
|
||||
|
||||
"made-private": ({ pageIds }: { pageIds: string[] }) => {
|
||||
"made-private": ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.makePrivate({ shouldSync: false });
|
||||
});
|
||||
},
|
||||
|
||||
deleted: ({ pageIds, data }: { pageIds: string[]; data: EventToPayloadMap["deleted"] }) => {
|
||||
deleted: ({ pageIds, data }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) {
|
||||
@@ -123,7 +123,7 @@ export const useRealtimePageEvents = ({
|
||||
});
|
||||
},
|
||||
|
||||
property_updated: ({ pageIds, data }: { pageIds: string[]; data: EventToPayloadMap["property_updated"] }) => {
|
||||
property_updated: ({ pageIds, data }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageInstance = getPageById(pageId);
|
||||
const { name: updatedName, ...rest } = data;
|
||||
@@ -132,7 +132,7 @@ export const useRealtimePageEvents = ({
|
||||
});
|
||||
},
|
||||
|
||||
error: ({ pageIds, data }: { pageIds: string[]; data: EventToPayloadMap["error"] }) => {
|
||||
error: ({ pageIds, data }) => {
|
||||
const errorType = data.error_type;
|
||||
const errorMessage = data.error_message || "An error occurred";
|
||||
const errorCode = data.error_code;
|
||||
@@ -164,9 +164,8 @@ export const useRealtimePageEvents = ({
|
||||
},
|
||||
|
||||
...customRealtimeEventHandlers,
|
||||
}),
|
||||
[getPageById, removePage, page, currentUser, getUserDisplayText, router, handlers, customRealtimeEventHandlers]
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
// The main function that will be returned from this hook
|
||||
const updatePageProperties = useCallback(
|
||||
@@ -182,7 +181,7 @@ export const useRealtimePageEvents = ({
|
||||
if (normalizedPageIds.length === 0) return;
|
||||
|
||||
// Get the handler for this message type
|
||||
const handler = ACTION_HANDLERS[actionType] as PageUpdateHandler<T> | undefined;
|
||||
const handler = ACTION_HANDLERS[actionType];
|
||||
|
||||
if (handler) {
|
||||
// Now TypeScript knows that handler and data match in type
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
IUserEmailNotificationSettings,
|
||||
TIssuesResponse,
|
||||
TUserProfile,
|
||||
IEmailCheckResponse,
|
||||
} from "@plane/types";
|
||||
import { APIService } from "@/services/api.service";
|
||||
// types
|
||||
@@ -259,38 +258,6 @@ export class UserService extends APIService {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async checkEmail(token: string, email: string): Promise<IEmailCheckResponse> {
|
||||
return this.post(
|
||||
"/auth/email-check/",
|
||||
{ email },
|
||||
{
|
||||
headers: {
|
||||
"X-CSRFTOKEN": token,
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async generateEmailCode(data: { email: string }): Promise<any> {
|
||||
return this.post("/api/users/me/email/generate-code/", data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async verifyEmailCode(data: { email: string; code: string }): Promise<any> {
|
||||
return this.patch("/api/users/me/email/", data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
+2
-3
@@ -14,8 +14,7 @@
|
||||
"fix:format": "turbo run fix:format",
|
||||
"check": "turbo run check",
|
||||
"check:lint": "turbo run check:lint",
|
||||
"check:format": "turbo run check:format",
|
||||
"check:types": "turbo run check:types"
|
||||
"check:format": "turbo run check:format"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@prettier/plugin-oxc": "0.0.4",
|
||||
@@ -51,4 +50,4 @@
|
||||
"engines": {
|
||||
"node": ">=22.18.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1522,47 +1522,6 @@ export default {
|
||||
"Pokud potvrdíte, všechny možnosti řazení, filtrování a zobrazení + rozvržení, které jste vybrali pro tento pohled, budou trvale odstraněny a nelze je obnovit.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Změnit e-mail",
|
||||
description: "Zadejte novou e-mailovou adresu a obdržíte ověřovací odkaz.",
|
||||
toasts: {
|
||||
success_title: "Úspěch!",
|
||||
success_message: "E-mail byl úspěšně aktualizován. Přihlaste se znovu.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Nový e-mail",
|
||||
placeholder: "Zadejte svůj e-mail",
|
||||
errors: {
|
||||
required: "E-mail je povinný",
|
||||
invalid: "E-mail je neplatný",
|
||||
exists: "E-mail již existuje. Použijte jiný.",
|
||||
validation_failed: "Ověření e-mailu se nezdařilo. Zkuste to znovu.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Jedinečný kód",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Ověřovací kód byl odeslán na váš nový e-mail.",
|
||||
errors: {
|
||||
required: "Jedinečný kód je povinný",
|
||||
invalid: "Neplatný ověřovací kód. Zkuste to znovu.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Pokračovat",
|
||||
confirm: "Potvrdit",
|
||||
cancel: "Zrušit",
|
||||
},
|
||||
states: {
|
||||
sending: "Odesílání…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Nastavení pracovního prostoru",
|
||||
page_label: "{workspace} - Obecná nastavení",
|
||||
|
||||
@@ -1540,47 +1540,6 @@ export default {
|
||||
"Wenn Sie bestätigen, werden alle Sortier-, Filter- und Anzeigeoptionen + das Layout, das Sie für diese Ansicht gewählt haben, dauerhaft gelöscht und können nicht wiederhergestellt werden.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "E-Mail ändern",
|
||||
description: "Gib eine neue E-Mail-Adresse ein, um einen Verifizierungslink zu erhalten.",
|
||||
toasts: {
|
||||
success_title: "Erfolg!",
|
||||
success_message: "E-Mail erfolgreich aktualisiert. Bitte melde dich erneut an.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Neue E-Mail",
|
||||
placeholder: "Gib deine E-Mail ein",
|
||||
errors: {
|
||||
required: "E-Mail ist erforderlich",
|
||||
invalid: "E-Mail ist ungültig",
|
||||
exists: "E-Mail existiert bereits. Bitte nutze eine andere.",
|
||||
validation_failed: "E-Mail-Verifizierung fehlgeschlagen. Bitte versuche es erneut.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Einmaliger Code",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Verifizierungscode wurde an deine neue E-Mail gesendet.",
|
||||
errors: {
|
||||
required: "Einmaliger Code ist erforderlich",
|
||||
invalid: "Ungültiger Verifizierungscode. Bitte versuche es erneut.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Weiter",
|
||||
confirm: "Bestätigen",
|
||||
cancel: "Abbrechen",
|
||||
},
|
||||
states: {
|
||||
sending: "Wird gesendet…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Arbeitsbereich-Einstellungen",
|
||||
page_label: "{workspace} - Allgemeine Einstellungen",
|
||||
|
||||
@@ -1357,45 +1357,7 @@ export default {
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Change email",
|
||||
description: "Enter a new email address to receive a verification link.",
|
||||
toasts: {
|
||||
success_title: "Success!",
|
||||
success_message: "Email updated successfully. Please sign in again.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "New email",
|
||||
placeholder: "Enter your email",
|
||||
errors: {
|
||||
required: "Email is required",
|
||||
invalid: "Email is invalid",
|
||||
exists: "Email already exists. Please use a different one.",
|
||||
validation_failed: "Email validation failed. Please try again.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Unique code",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Verification code sent to your new email.",
|
||||
errors: {
|
||||
required: "Unique code is required",
|
||||
invalid: "Invalid verification code. Please try again.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Continue",
|
||||
confirm: "Confirm",
|
||||
cancel: "Cancel",
|
||||
},
|
||||
states: {
|
||||
sending: "Sending",
|
||||
},
|
||||
},
|
||||
},
|
||||
profile: {},
|
||||
preferences: {
|
||||
heading: "Preferences",
|
||||
description: "Customize your app experience the way you work",
|
||||
|
||||
@@ -1544,47 +1544,6 @@ export default {
|
||||
"Si confirmas, todas las opciones de ordenación, filtro y visualización + el diseño que has elegido para esta vista se eliminarán permanentemente sin posibilidad de restaurarlas.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Cambiar correo electrónico",
|
||||
description: "Introduce una nueva dirección de correo electrónico para recibir un enlace de verificación.",
|
||||
toasts: {
|
||||
success_title: "¡Éxito!",
|
||||
success_message: "Correo electrónico actualizado correctamente. Inicia sesión de nuevo.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Nuevo correo electrónico",
|
||||
placeholder: "Introduce tu correo electrónico",
|
||||
errors: {
|
||||
required: "El correo electrónico es obligatorio",
|
||||
invalid: "El correo electrónico no es válido",
|
||||
exists: "El correo electrónico ya existe. Usa uno diferente.",
|
||||
validation_failed: "La validación del correo electrónico falló. Inténtalo de nuevo.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Código único",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Código de verificación enviado a tu nuevo correo electrónico.",
|
||||
errors: {
|
||||
required: "El código único es obligatorio",
|
||||
invalid: "Código de verificación inválido. Inténtalo de nuevo.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Continuar",
|
||||
confirm: "Confirmar",
|
||||
cancel: "Cancelar",
|
||||
},
|
||||
states: {
|
||||
sending: "Enviando…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Configuración del espacio de trabajo",
|
||||
page_label: "{workspace} - Configuración general",
|
||||
|
||||
@@ -1542,47 +1542,6 @@ export default {
|
||||
"Si vous confirmez, toutes les options de tri, de filtrage et d’affichage et la mise en page que vous avez choisie pour cette vue seront définitivement supprimées sans possibilité de les restaurer.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Changer d’adresse e-mail",
|
||||
description: "Saisissez une nouvelle adresse e-mail pour recevoir un lien de vérification.",
|
||||
toasts: {
|
||||
success_title: "Succès !",
|
||||
success_message: "Adresse e-mail mise à jour. Veuillez vous reconnecter.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Nouvelle adresse e-mail",
|
||||
placeholder: "Saisissez votre e-mail",
|
||||
errors: {
|
||||
required: "L’e-mail est requis",
|
||||
invalid: "L’e-mail est invalide",
|
||||
exists: "Cette adresse e-mail existe déjà. Utilisez-en une autre.",
|
||||
validation_failed: "Échec de la validation de l’e-mail. Veuillez réessayer.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Code unique",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Code de vérification envoyé à votre nouvel e-mail.",
|
||||
errors: {
|
||||
required: "Le code unique est requis",
|
||||
invalid: "Code de vérification invalide. Veuillez réessayer.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Continuer",
|
||||
confirm: "Confirmer",
|
||||
cancel: "Annuler",
|
||||
},
|
||||
states: {
|
||||
sending: "Envoi…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Paramètres de l’espace de travail",
|
||||
page_label: "{workspace} - Paramètres généraux",
|
||||
|
||||
@@ -1530,47 +1530,6 @@ export default {
|
||||
"Jika Anda mengonfirmasi, semua opsi pengurutan, filter, dan tampilan + tata letak yang telah Anda pilih untuk tampilan ini akan dihapus secara permanen tanpa cara untuk memulihkannya.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Ubah email",
|
||||
description: "Masukkan alamat email baru untuk menerima tautan verifikasi.",
|
||||
toasts: {
|
||||
success_title: "Berhasil!",
|
||||
success_message: "Email berhasil diperbarui. Silakan masuk kembali.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Email baru",
|
||||
placeholder: "Masukkan email Anda",
|
||||
errors: {
|
||||
required: "Email wajib diisi",
|
||||
invalid: "Email tidak valid",
|
||||
exists: "Email sudah ada. Gunakan yang lain.",
|
||||
validation_failed: "Validasi email gagal. Coba lagi.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Kode unik",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Kode verifikasi dikirim ke email baru Anda.",
|
||||
errors: {
|
||||
required: "Kode unik wajib diisi",
|
||||
invalid: "Kode verifikasi tidak valid. Coba lagi.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Lanjutkan",
|
||||
confirm: "Konfirmasi",
|
||||
cancel: "Batal",
|
||||
},
|
||||
states: {
|
||||
sending: "Mengirim…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Pengaturan ruang kerja",
|
||||
page_label: "{workspace} - Pengaturan Umum",
|
||||
|
||||
@@ -1534,47 +1534,6 @@ export default {
|
||||
"Se confermi, tutte le opzioni di ordinamento, filtro e visualizzazione + il layout che hai scelto per questa visualizzazione saranno eliminate permanentemente senza possibilità di ripristinarle.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Cambia email",
|
||||
description: "Inserisci un nuovo indirizzo email per ricevere un link di verifica.",
|
||||
toasts: {
|
||||
success_title: "Successo!",
|
||||
success_message: "Email aggiornata con successo. Accedi di nuovo.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Nuova email",
|
||||
placeholder: "Inserisci la tua email",
|
||||
errors: {
|
||||
required: "L’email è obbligatoria",
|
||||
invalid: "L’email non è valida",
|
||||
exists: "L’email esiste già. Usane un’altra.",
|
||||
validation_failed: "La verifica dell’email non è riuscita. Riprova.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Codice univoco",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Codice di verifica inviato alla tua nuova email.",
|
||||
errors: {
|
||||
required: "Il codice univoco è obbligatorio",
|
||||
invalid: "Codice di verifica non valido. Riprova.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Continua",
|
||||
confirm: "Conferma",
|
||||
cancel: "Annulla",
|
||||
},
|
||||
states: {
|
||||
sending: "Invio…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Impostazioni dello spazio di lavoro",
|
||||
page_label: "{workspace} - Impostazioni generali",
|
||||
|
||||
@@ -1521,47 +1521,6 @@ export default {
|
||||
"確認すると、このビューに選択したすべてのソート、フィルター、表示オプション + レイアウトが復元不可能な形で完全に削除されます。",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "メールアドレスを変更",
|
||||
description: "確認リンクを受け取るには、新しいメールアドレスを入力してください。",
|
||||
toasts: {
|
||||
success_title: "成功",
|
||||
success_message: "メールアドレスを更新しました。再度サインインしてください。",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "新しいメールアドレス",
|
||||
placeholder: "メールアドレスを入力",
|
||||
errors: {
|
||||
required: "メールアドレスは必須です",
|
||||
invalid: "メールアドレスが無効です",
|
||||
exists: "メールアドレスは既に存在します。別のものを使用してください。",
|
||||
validation_failed: "メールアドレスの確認に失敗しました。もう一度お試しください。",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "認証コード",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "認証コードを新しいメールに送信しました。",
|
||||
errors: {
|
||||
required: "認証コードは必須です",
|
||||
invalid: "認証コードが無効です。もう一度お試しください。",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "続行",
|
||||
confirm: "確認",
|
||||
cancel: "キャンセル",
|
||||
},
|
||||
states: {
|
||||
sending: "送信中…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "ワークスペース設定",
|
||||
page_label: "{workspace} - 一般設定",
|
||||
|
||||
@@ -1514,47 +1514,6 @@ export default {
|
||||
"확인하면 이 뷰에 대해 선택한 모든 정렬, 필터 및 표시 옵션 + 레이아웃이 복원할 수 없는 방식으로 영구적으로 삭제됩니다.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "이메일 변경",
|
||||
description: "확인 링크를 받으려면 새 이메일 주소를 입력하세요.",
|
||||
toasts: {
|
||||
success_title: "성공!",
|
||||
success_message: "이메일이 업데이트되었습니다. 다시 로그인하세요.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "새 이메일",
|
||||
placeholder: "이메일을 입력하세요",
|
||||
errors: {
|
||||
required: "이메일은 필수입니다",
|
||||
invalid: "유효하지 않은 이메일입니다",
|
||||
exists: "이미 존재하는 이메일입니다. 다른 주소를 사용하세요.",
|
||||
validation_failed: "이메일 확인에 실패했습니다. 다시 시도하세요.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "고유 코드",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "인증 코드가 새 이메일로 전송되었습니다.",
|
||||
errors: {
|
||||
required: "고유 코드는 필수입니다",
|
||||
invalid: "잘못된 인증 코드입니다. 다시 시도하세요.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "계속",
|
||||
confirm: "확인",
|
||||
cancel: "취소",
|
||||
},
|
||||
states: {
|
||||
sending: "전송 중…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "작업 공간 설정",
|
||||
page_label: "{workspace} - 일반 설정",
|
||||
|
||||
@@ -1525,47 +1525,6 @@ export default {
|
||||
"Jeśli potwierdzisz, wszystkie opcje sortowania, filtrowania i wyświetlania + układ, który wybrałeś dla tego widoku, zostaną trwale usunięte bez możliwości przywrócenia.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Zmień e-mail",
|
||||
description: "Wpisz nowy adres e-mail, aby otrzymać link weryfikacyjny.",
|
||||
toasts: {
|
||||
success_title: "Sukces!",
|
||||
success_message: "E-mail zaktualizowano. Zaloguj się ponownie.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Nowy e-mail",
|
||||
placeholder: "Wpisz swój e-mail",
|
||||
errors: {
|
||||
required: "E-mail jest wymagany",
|
||||
invalid: "E-mail jest nieprawidłowy",
|
||||
exists: "E-mail już istnieje. Użyj innego.",
|
||||
validation_failed: "Weryfikacja e-maila nie powiodła się. Spróbuj ponownie.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Unikalny kod",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Kod weryfikacyjny wysłano na nowy e-mail.",
|
||||
errors: {
|
||||
required: "Unikalny kod jest wymagany",
|
||||
invalid: "Nieprawidłowy kod weryfikacyjny. Spróbuj ponownie.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Kontynuuj",
|
||||
confirm: "Potwierdź",
|
||||
cancel: "Anuluj",
|
||||
},
|
||||
states: {
|
||||
sending: "Wysyłanie…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Ustawienia przestrzeni roboczej",
|
||||
page_label: "{workspace} - Ustawienia ogólne",
|
||||
|
||||
@@ -1542,47 +1542,6 @@ export default {
|
||||
"Se você confirmar, todas as opções de classificação, filtro e exibição + o layout que você escolheu para esta visualização serão excluídos permanentemente sem nenhuma maneira de restaurá-los.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Alterar e-mail",
|
||||
description: "Digite um novo endereço de e-mail para receber um link de verificação.",
|
||||
toasts: {
|
||||
success_title: "Sucesso!",
|
||||
success_message: "E-mail atualizado com sucesso. Faça login novamente.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Novo e-mail",
|
||||
placeholder: "Digite seu e-mail",
|
||||
errors: {
|
||||
required: "O e-mail é obrigatório",
|
||||
invalid: "O e-mail é inválido",
|
||||
exists: "O e-mail já existe. Use outro.",
|
||||
validation_failed: "Falha na validação do e-mail. Tente novamente.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Código único",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Código de verificação enviado para o novo e-mail.",
|
||||
errors: {
|
||||
required: "O código único é obrigatório",
|
||||
invalid: "Código de verificação inválido. Tente novamente.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Continuar",
|
||||
confirm: "Confirmar",
|
||||
cancel: "Cancelar",
|
||||
},
|
||||
states: {
|
||||
sending: "Enviando…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Configurações do espaço de trabalho",
|
||||
page_label: "{workspace} - Configurações gerais",
|
||||
|
||||
@@ -1534,47 +1534,6 @@ export default {
|
||||
"Dacă confirmați, toate opțiunile de sortare, filtrare și afișare + aspectul pe care l-ați ales pentru această vizualizare vor fi șterse permanent fără nicio modalitate de a le restaura.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Schimbă e-mailul",
|
||||
description: "Introduceți o nouă adresă de e-mail pentru a primi un link de verificare.",
|
||||
toasts: {
|
||||
success_title: "Succes!",
|
||||
success_message: "E-mail actualizat cu succes. Conectați-vă din nou.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "E-mail nou",
|
||||
placeholder: "Introduceți e-mailul",
|
||||
errors: {
|
||||
required: "E-mailul este obligatoriu",
|
||||
invalid: "E-mailul este invalid",
|
||||
exists: "E-mailul există deja. Folosiți altul.",
|
||||
validation_failed: "Validarea e-mailului a eșuat. Încercați din nou.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Cod unic",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Codul de verificare a fost trimis la noul e-mail.",
|
||||
errors: {
|
||||
required: "Codul unic este obligatoriu",
|
||||
invalid: "Cod de verificare invalid. Încercați din nou.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Continuă",
|
||||
confirm: "Confirmă",
|
||||
cancel: "Anulează",
|
||||
},
|
||||
states: {
|
||||
sending: "Se trimite…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Setări spațiu de lucru",
|
||||
page_label: "{workspace} - Setări generale",
|
||||
|
||||
@@ -1527,47 +1527,6 @@ export default {
|
||||
"При подтверждении все параметры сортировки, фильтрации и отображения + макет, выбранный для этого представления, будут безвозвратно удалены без возможности восстановления.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Изменить email",
|
||||
description: "Введите новый адрес электронной почты, чтобы получить ссылку для подтверждения.",
|
||||
toasts: {
|
||||
success_title: "Успех!",
|
||||
success_message: "Email успешно обновлён. Пожалуйста, войдите снова.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Новый email",
|
||||
placeholder: "Введите свой email",
|
||||
errors: {
|
||||
required: "Email обязателен",
|
||||
invalid: "Email недействителен",
|
||||
exists: "Email уже существует. Используйте другой.",
|
||||
validation_failed: "Не удалось подтвердить email. Попробуйте ещё раз.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Уникальный код",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Код подтверждения отправлен на ваш новый email.",
|
||||
errors: {
|
||||
required: "Уникальный код обязателен",
|
||||
invalid: "Неверный код подтверждения. Попробуйте ещё раз.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Продолжить",
|
||||
confirm: "Подтвердить",
|
||||
cancel: "Отмена",
|
||||
},
|
||||
states: {
|
||||
sending: "Отправка…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Настройки пространства",
|
||||
page_label: "{workspace} - Основные настройки",
|
||||
|
||||
@@ -1525,47 +1525,6 @@ export default {
|
||||
"Ak potvrdíte, všetky možnosti triedenia, filtrovania a zobrazenia + rozloženie, ktoré ste vybrali pre toto zobrazenie, budú natrvalo vymazané bez možnosti obnovenia.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Zmeniť e-mail",
|
||||
description: "Zadajte novú e-mailovú adresu, aby ste dostali overovací odkaz.",
|
||||
toasts: {
|
||||
success_title: "Úspech!",
|
||||
success_message: "E-mail bol úspešne aktualizovaný. Prihláste sa znova.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Nový e-mail",
|
||||
placeholder: "Zadajte svoj e-mail",
|
||||
errors: {
|
||||
required: "E-mail je povinný",
|
||||
invalid: "E-mail je neplatný",
|
||||
exists: "E-mail už existuje. Použite iný.",
|
||||
validation_failed: "Overenie e-mailu zlyhalo. Skúste znova.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Jedinečný kód",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Overovací kód bol odoslaný na váš nový e-mail.",
|
||||
errors: {
|
||||
required: "Jedinečný kód je povinný",
|
||||
invalid: "Neplatný overovací kód. Skúste znova.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Pokračovať",
|
||||
confirm: "Potvrdiť",
|
||||
cancel: "Zrušiť",
|
||||
},
|
||||
states: {
|
||||
sending: "Odosielanie…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Nastavenia pracovného priestoru",
|
||||
page_label: "{workspace} - Všeobecné nastavenia",
|
||||
|
||||
@@ -1529,47 +1529,6 @@ export default {
|
||||
"Onaylarsanız, bu görünüm için seçtiğiniz tüm sıralama, filtreleme ve görüntüleme seçenekleri + düzen kalıcı olarak silinecek ve geri yükleme imkanı olmayacaktır.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "E-postayı değiştir",
|
||||
description: "Doğrulama bağlantısı almak için yeni bir e-posta adresi girin.",
|
||||
toasts: {
|
||||
success_title: "Başarılı!",
|
||||
success_message: "E-posta başarıyla güncellendi. Lütfen tekrar giriş yapın.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Yeni e-posta",
|
||||
placeholder: "E-postanızı girin",
|
||||
errors: {
|
||||
required: "E-posta zorunludur",
|
||||
invalid: "E-posta geçersiz",
|
||||
exists: "E-posta zaten mevcut. Başka bir tane kullanın.",
|
||||
validation_failed: "E-posta doğrulaması başarısız oldu. Lütfen tekrar deneyin.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Benzersiz kod",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Doğrulama kodu yeni e-postanıza gönderildi.",
|
||||
errors: {
|
||||
required: "Benzersiz kod zorunludur",
|
||||
invalid: "Geçersiz doğrulama kodu. Lütfen tekrar deneyin.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Devam et",
|
||||
confirm: "Onayla",
|
||||
cancel: "İptal et",
|
||||
},
|
||||
states: {
|
||||
sending: "Gönderiliyor…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Çalışma Alanı Ayarları",
|
||||
page_label: "{workspace} - Genel ayarlar",
|
||||
|
||||
@@ -1529,47 +1529,6 @@ export default {
|
||||
"Якщо ви підтвердите, всі параметри сортування, фільтрації та відображення + макет, який ви обрали для цього подання, будуть безповоротно видалені без можливості відновлення.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Змінити email",
|
||||
description: "Введіть нову адресу електронної пошти, щоб отримати посилання для підтвердження.",
|
||||
toasts: {
|
||||
success_title: "Успіх!",
|
||||
success_message: "Email успішно оновлено. Увійдіть знову.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Новий email",
|
||||
placeholder: "Введіть свій email",
|
||||
errors: {
|
||||
required: "Email є обов’язковим",
|
||||
invalid: "Email недійсний",
|
||||
exists: "Email уже існує. Використайте інший.",
|
||||
validation_failed: "Не вдалося підтвердити email. Спробуйте ще раз.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Унікальний код",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Код підтвердження надіслано на ваш новий email.",
|
||||
errors: {
|
||||
required: "Унікальний код є обов’язковим",
|
||||
invalid: "Недійсний код підтвердження. Спробуйте ще раз.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Продовжити",
|
||||
confirm: "Підтвердити",
|
||||
cancel: "Скасувати",
|
||||
},
|
||||
states: {
|
||||
sending: "Надсилання…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Налаштування робочого простору",
|
||||
page_label: "{workspace} - Загальні налаштування",
|
||||
|
||||
@@ -1531,47 +1531,6 @@ export default {
|
||||
"Nếu bạn xác nhận, tất cả các tùy chọn sắp xếp, lọc và hiển thị + bố cục mà bạn đã chọn cho chế độ xem này sẽ bị xóa vĩnh viễn mà không có cách nào khôi phục.",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "Đổi email",
|
||||
description: "Nhập địa chỉ email mới để nhận liên kết xác minh.",
|
||||
toasts: {
|
||||
success_title: "Thành công!",
|
||||
success_message: "Email đã được cập nhật. Vui lòng đăng nhập lại.",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "Email mới",
|
||||
placeholder: "Nhập email của bạn",
|
||||
errors: {
|
||||
required: "Email là bắt buộc",
|
||||
invalid: "Email không hợp lệ",
|
||||
exists: "Email đã tồn tại. Vui lòng dùng email khác.",
|
||||
validation_failed: "Xác thực email thất bại. Thử lại.",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "Mã duy nhất",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "Mã xác minh đã được gửi tới email mới của bạn.",
|
||||
errors: {
|
||||
required: "Mã duy nhất là bắt buộc",
|
||||
invalid: "Mã xác minh không hợp lệ. Thử lại.",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "Tiếp tục",
|
||||
confirm: "Xác nhận",
|
||||
cancel: "Hủy",
|
||||
},
|
||||
states: {
|
||||
sending: "Đang gửi…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "Cài đặt không gian làm việc",
|
||||
page_label: "{workspace} - Cài đặt chung",
|
||||
|
||||
@@ -1505,47 +1505,6 @@ export default {
|
||||
content: "如果您确认,您为此视图选择的所有排序、筛选和显示选项 + 布局将被永久删除,无法恢复。",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "更改邮箱",
|
||||
description: "请输入新的邮箱地址以接收验证链接。",
|
||||
toasts: {
|
||||
success_title: "成功!",
|
||||
success_message: "邮箱已更新,请重新登录。",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "新邮箱",
|
||||
placeholder: "请输入邮箱",
|
||||
errors: {
|
||||
required: "邮箱为必填项",
|
||||
invalid: "邮箱格式无效",
|
||||
exists: "邮箱已存在,请使用其他邮箱。",
|
||||
validation_failed: "邮箱验证失败,请重试。",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "验证码",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "验证码已发送至你的新邮箱。",
|
||||
errors: {
|
||||
required: "验证码为必填项",
|
||||
invalid: "验证码无效,请重试。",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "继续",
|
||||
confirm: "确认",
|
||||
cancel: "取消",
|
||||
},
|
||||
states: {
|
||||
sending: "发送中…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "工作区设置",
|
||||
page_label: "{workspace} - 常规设置",
|
||||
|
||||
@@ -1506,47 +1506,6 @@ export default {
|
||||
content: "如果您確認,您為此視圖選擇的所有排序、篩選和顯示選項 + 布局將被永久刪除,無法恢復。",
|
||||
},
|
||||
},
|
||||
account_settings: {
|
||||
profile: {
|
||||
change_email_modal: {
|
||||
title: "變更電子郵件",
|
||||
description: "請輸入新的電子郵件地址以接收驗證連結。",
|
||||
toasts: {
|
||||
success_title: "成功!",
|
||||
success_message: "電子郵件已更新,請重新登入。",
|
||||
},
|
||||
form: {
|
||||
email: {
|
||||
label: "新電子郵件",
|
||||
placeholder: "請輸入電子郵件",
|
||||
errors: {
|
||||
required: "電子郵件為必填",
|
||||
invalid: "電子郵件格式無效",
|
||||
exists: "電子郵件已存在,請使用其他信箱。",
|
||||
validation_failed: "電子郵件驗證失敗,請再試一次。",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
label: "驗證碼",
|
||||
placeholder: "gets-sets-flys",
|
||||
helper_text: "驗證碼已傳送到你的新電子郵件。",
|
||||
errors: {
|
||||
required: "驗證碼為必填",
|
||||
invalid: "驗證碼無效,請再試一次。",
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
continue: "繼續",
|
||||
confirm: "確認",
|
||||
cancel: "取消",
|
||||
},
|
||||
states: {
|
||||
sending: "傳送中…",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspace_settings: {
|
||||
label: "工作區設定",
|
||||
page_label: "{workspace} - 一般設定",
|
||||
|
||||
@@ -45,10 +45,7 @@ const getPositionClassNames = (position: DialogPosition) =>
|
||||
"top-8 left-1/2 -translate-x-1/2": position === "top",
|
||||
});
|
||||
|
||||
const DialogPortal = memo(function DialogPortal({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseDialog.Portal>) {
|
||||
const DialogPortal = memo(function DialogPortal({ children, ...props }) {
|
||||
return (
|
||||
<BaseDialog.Portal data-slot="dialog-portal" {...props}>
|
||||
{children}
|
||||
@@ -57,15 +54,12 @@ const DialogPortal = memo(function DialogPortal({
|
||||
});
|
||||
DialogPortal.displayName = "DialogPortal";
|
||||
|
||||
const DialogOverlay = memo(function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseDialog.Backdrop>) {
|
||||
const DialogOverlay = memo(function DialogOverlay({ className, ...props }) {
|
||||
return <BaseDialog.Backdrop data-slot="dialog-overlay" className={cn(OVERLAY_CLASSNAME, className)} {...props} />;
|
||||
});
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
const DialogComponent = memo(function DialogComponent({ children, ...props }: DialogProps) {
|
||||
const DialogComponent = memo(function DialogComponent({ children, ...props }) {
|
||||
return (
|
||||
<BaseDialog.Root data-slot="dialog" {...props}>
|
||||
{children}
|
||||
@@ -74,10 +68,7 @@ const DialogComponent = memo(function DialogComponent({ children, ...props }: Di
|
||||
});
|
||||
DialogComponent.displayName = "Dialog";
|
||||
|
||||
const DialogTrigger = memo(function DialogTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseDialog.Trigger>) {
|
||||
const DialogTrigger = memo(function DialogTrigger({ children, ...props }) {
|
||||
return (
|
||||
<BaseDialog.Trigger data-slot="dialog-trigger" {...props}>
|
||||
{children}
|
||||
@@ -109,7 +100,7 @@ const DialogPanel = forwardRef(function DialogPanel(
|
||||
});
|
||||
DialogPanel.displayName = "DialogPanel";
|
||||
|
||||
const DialogTitle = memo(function DialogTitle({ className, children, ...props }: DialogTitleProps) {
|
||||
const DialogTitle = memo(function DialogTitle({ className, children, ...props }) {
|
||||
return (
|
||||
<BaseDialog.Title
|
||||
data-slot="dialog-title"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import * as React from "react";
|
||||
import { Popover as BasePopover } from "@base-ui-components/react/popover";
|
||||
import type { TPlacement, TSide, TAlign } from "../utils/placement";
|
||||
import { convertPlacementToSideAndAlign } from "../utils/placement";
|
||||
@@ -13,7 +13,7 @@ export interface PopoverContentProps extends React.ComponentProps<typeof BasePop
|
||||
}
|
||||
|
||||
// PopoverContent component
|
||||
const PopoverContent = memo(function PopoverContent({
|
||||
const PopoverContent = React.memo(function PopoverContent({
|
||||
children,
|
||||
className,
|
||||
placement,
|
||||
@@ -23,9 +23,9 @@ const PopoverContent = memo(function PopoverContent({
|
||||
containerRef,
|
||||
positionerClassName,
|
||||
...props
|
||||
}: PopoverContentProps) {
|
||||
}) {
|
||||
// side and align calculations
|
||||
const { finalSide, finalAlign } = useMemo(() => {
|
||||
const { finalSide, finalAlign } = React.useMemo(() => {
|
||||
if (placement) {
|
||||
const converted = convertPlacementToSideAndAlign(placement);
|
||||
return { finalSide: converted.side, finalAlign: converted.align };
|
||||
@@ -45,21 +45,21 @@ const PopoverContent = memo(function PopoverContent({
|
||||
});
|
||||
|
||||
// wrapper components
|
||||
const PopoverTrigger = memo(function PopoverTrigger(props: React.ComponentProps<typeof BasePopover.Trigger>) {
|
||||
const PopoverTrigger = React.memo(function PopoverTrigger(props) {
|
||||
return <BasePopover.Trigger data-slot="popover-trigger" {...props} />;
|
||||
});
|
||||
|
||||
const PopoverPortal = memo(function PopoverPortal(props: React.ComponentProps<typeof BasePopover.Portal>) {
|
||||
const PopoverPortal = React.memo(function PopoverPortal(props) {
|
||||
return <BasePopover.Portal data-slot="popover-portal" {...props} />;
|
||||
});
|
||||
|
||||
const PopoverPositioner = memo(function PopoverPositioner(props: React.ComponentProps<typeof BasePopover.Positioner>) {
|
||||
const PopoverPositioner = React.memo(function PopoverPositioner(props) {
|
||||
return <BasePopover.Positioner data-slot="popover-positioner" {...props} />;
|
||||
});
|
||||
|
||||
// compound components
|
||||
const Popover = Object.assign(
|
||||
memo(function Popover(props: React.ComponentProps<typeof BasePopover.Root>) {
|
||||
React.memo<React.ComponentProps<typeof BasePopover.Root>>(function Popover(props) {
|
||||
return <BasePopover.Root data-slot="popover" {...props} />;
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -37,4 +37,4 @@ const Skeleton = Object.assign(SkeletonRoot, { Item: SkeletonItem });
|
||||
SkeletonRoot.displayName = "plane-ui-skeleton";
|
||||
SkeletonItem.displayName = "plane-ui-skeleton-item";
|
||||
|
||||
export { Skeleton, SkeletonRoot, SkeletonItem };
|
||||
export { Skeleton };
|
||||
|
||||
@@ -41,7 +41,7 @@ const TabsList = React.forwardRef(function TabsList(
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1.5 rounded-md text-sm p-0.5 bg-custom-background-80/60 relative overflow-auto",
|
||||
"flex w-full min-w-fit items-center justify-between gap-1.5 rounded-md text-sm p-0.5 bg-custom-background-80/60 relative overflow-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@prettier/plugin-oxc": "0.0.4",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
|
||||
@@ -29,7 +29,7 @@ export function BreadcrumbNavigationDropdown(props: TBreadcrumbNavigationDropdow
|
||||
|
||||
function NavigationButton() {
|
||||
return (
|
||||
<Tooltip tooltipContent={selectedItem?.title} position="bottom" disabled={isOpen}>
|
||||
<Tooltip tooltipContent={selectedItem.title} position="bottom" disabled={isOpen}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
if (!isLast) {
|
||||
@@ -48,7 +48,7 @@ export function BreadcrumbNavigationDropdown(props: TBreadcrumbNavigationDropdow
|
||||
<div className="flex @4xl:hidden text-custom-text-300">...</div>
|
||||
<div className="hidden @4xl:flex gap-2">
|
||||
{selectedItemIcon && <Breadcrumbs.Icon>{selectedItemIcon}</Breadcrumbs.Icon>}
|
||||
<Breadcrumbs.Label>{selectedItem?.title}</Breadcrumbs.Label>
|
||||
<Breadcrumbs.Label>{selectedItem.title}</Breadcrumbs.Label>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
Generated
+4
-549
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -12,7 +12,7 @@ catalog:
|
||||
"@react-router/dev": 7.9.5
|
||||
"@react-router/node": 7.9.5
|
||||
"@react-router/serve": 7.9.5
|
||||
"@sentry/node": 10.27.0
|
||||
"@sentry/node": 10.24.0
|
||||
"@sentry/profiling-node": 10.24.0
|
||||
"@sentry/react-router": 10.24.0
|
||||
"@tiptap/core": ^2.22.3
|
||||
|
||||
Reference in New Issue
Block a user