Compare commits

..
90 changed files with 417 additions and 4270 deletions
@@ -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;
-13
View File
@@ -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:
+6
View File
@@ -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",
),
]
-10
View File
@@ -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
-5
View File
@@ -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",
),
]
+1
View File
@@ -18,6 +18,7 @@ from .project.member import (
ProjectMemberViewSet,
ProjectMemberUserEndpoint,
UserProjectRolesEndpoint,
ProjectMemberPreferenceEndpoint,
)
from .user.base import (
+3 -16
View File
@@ -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)
+3 -186
View File
@@ -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 -20
View File
@@ -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'),
),
]
@@ -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'),
),
]
-1
View File
@@ -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):
+2 -57
View File
@@ -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"
-20
View File
@@ -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):
+1 -1
View File
@@ -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):
+1 -10
View File
@@ -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"]}
+27 -169
View File
@@ -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
+1 -2
View File
@@ -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 -8
View File
@@ -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}\"",
+4
View File
@@ -1,6 +1,10 @@
{
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"module": "ES2015",
"moduleResolution": "Bundler",
"lib": ["ES2015"],
"target": "ES2015",
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
-1
View File
@@ -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;
}
+1
View File
@@ -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">
-16
View File
@@ -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
-33
View File
@@ -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
View File
@@ -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",
+1 -39
View File
@@ -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 daffichage 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 dadresse 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: "Le-mail est requis",
invalid: "Le-mail est invalide",
exists: "Cette adresse e-mail existe déjà. Utilisez-en une autre.",
validation_failed: "Échec de la validation de le-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 lespace 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: "Lemail è obbligatoria",
invalid: "Lemail non è valida",
exists: "Lemail esiste già. Usane unaltra.",
validation_failed: "La verifica dellemail 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} - 一般設定",
+5 -14
View File
@@ -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"
+8 -8
View File
@@ -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} />;
}),
{
+1 -1
View File
@@ -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 };
+1 -1
View File
@@ -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}
-1
View File
@@ -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>
+4 -549
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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