From ea1f92e0c6f892f25aaa5b1fb6ca6164dbd00640 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:37:19 +0530 Subject: [PATCH 01/63] [WEB-5537]refactor: rename IssueUserProperty to ProjectUserProperty and update related references (#8206) * refactor: rename IssueUserProperty to ProjectUserProperty and update related references across the codebase * migrate: move issue user properties to project user properties and update related fields and constraints * refactor: rename IssueUserPropertySerializer and IssueUserDisplayPropertyEndpoint to ProjectUserPropertySerializer and ProjectUserDisplayPropertyEndpoint, updating all related references * fix: enhance ProjectUserDisplayPropertyEndpoint to handle missing properties by creating new entries and improve response handling * fix: correct formatting in migration for ProjectUserProperty model options * migrate: add migration to update existing non-service API tokens to remove workspace association * migrate: refine migration to update existing non-service API tokens by excluding bot users from workspace removal * chore: changed the project sort order in project user property * chore: remove allowed_rate_limit from APIToken * chore: updated user-properties endpoint for frontend * chore: removed the extra projectuserproperty * chore: updated the migration file * chore: code refactor * fix: type error --------- Co-authored-by: NarayanBavisetti Co-authored-by: sangeethailango Co-authored-by: vamsikrishnamathala Co-authored-by: Anmol Singh Bhatia --- apps/api/plane/api/views/project.py | 9 +- apps/api/plane/app/serializers/__init__.py | 2 +- apps/api/plane/app/serializers/issue.py | 6 +- apps/api/plane/app/urls/issue.py | 8 +- apps/api/plane/app/views/__init__.py | 2 +- apps/api/plane/app/views/issue/base.py | 36 +++++--- apps/api/plane/app/views/project/base.py | 10 +-- apps/api/plane/app/views/project/invite.py | 8 +- apps/api/plane/app/views/project/member.py | 32 +++---- apps/api/plane/bgtasks/workspace_seed_task.py | 6 +- .../commands/create_project_member.py | 17 +--- ...perty_delete_issueuserproperty_and_more.py | 50 +++++++++++ .../db/migrations/0115_auto_20260105_0836.py | 51 +++++++++++ apps/api/plane/db/models/__init__.py | 2 +- apps/api/plane/db/models/issue.py | 30 ------- apps/api/plane/db/models/project.py | 55 ++++++++++-- .../tests/contract/app/test_project_app.py | 10 +-- .../navigation/use-tab-preferences.ts | 32 +++---- .../layouts/auth-layout/project-wrapper.tsx | 4 +- .../web/core/services/issue_filter.service.ts | 20 ----- .../project/project-member.service.ts | 33 +------- .../core/services/project/project.service.ts | 20 +++-- .../core/store/issue/project/filter.store.ts | 14 ++-- .../project/base-project-member.store.ts | 84 ++++++++----------- apps/web/core/store/project/project.store.ts | 2 +- packages/types/src/project/projects.ts | 6 -- packages/types/src/view-props.ts | 11 +++ 27 files changed, 304 insertions(+), 256 deletions(-) create mode 100644 apps/api/plane/db/migrations/0114_projectuserproperty_delete_issueuserproperty_and_more.py create mode 100644 apps/api/plane/db/migrations/0115_auto_20260105_0836.py diff --git a/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py index fbf6790516..7e592a9aaf 100644 --- a/apps/api/plane/api/views/project.py +++ b/apps/api/plane/api/views/project.py @@ -18,7 +18,7 @@ from drf_spectacular.utils import OpenApiResponse, OpenApiRequest from plane.db.models import ( Cycle, Intake, - IssueUserProperty, + ProjectUserProperty, Module, Project, DeployBoard, @@ -218,8 +218,6 @@ class ProjectListCreateAPIEndpoint(BaseAPIView): # Add the user as Administrator to the project _ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20) - # Also create the issue property for the user - _ = IssueUserProperty.objects.create(project_id=serializer.instance.id, user=request.user) if serializer.instance.project_lead is not None and str(serializer.instance.project_lead) != str( request.user.id @@ -229,11 +227,6 @@ class ProjectListCreateAPIEndpoint(BaseAPIView): member_id=serializer.instance.project_lead, role=20, ) - # Also create the issue property for the user - IssueUserProperty.objects.create( - project_id=serializer.instance.id, - user_id=serializer.instance.project_lead, - ) State.objects.bulk_create( [ diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py index 759f27ed6e..96046187f9 100644 --- a/apps/api/plane/app/serializers/__init__.py +++ b/apps/api/plane/app/serializers/__init__.py @@ -52,7 +52,7 @@ from .issue import ( IssueCreateSerializer, IssueActivitySerializer, IssueCommentSerializer, - IssueUserPropertySerializer, + ProjectUserPropertySerializer, IssueAssigneeSerializer, LabelSerializer, IssueSerializer, diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 5e3b93ab67..c8f530ba18 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -18,7 +18,7 @@ from plane.db.models import ( Issue, IssueActivity, IssueComment, - IssueUserProperty, + ProjectUserProperty, IssueAssignee, IssueSubscriber, IssueLabel, @@ -346,9 +346,9 @@ class IssueActivitySerializer(BaseSerializer): fields = "__all__" -class IssueUserPropertySerializer(BaseSerializer): +class ProjectUserPropertySerializer(BaseSerializer): class Meta: - model = IssueUserProperty + model = ProjectUserProperty fields = "__all__" read_only_fields = ["user", "workspace", "project"] diff --git a/apps/api/plane/app/urls/issue.py b/apps/api/plane/app/urls/issue.py index 1d809e248f..1fd7741206 100644 --- a/apps/api/plane/app/urls/issue.py +++ b/apps/api/plane/app/urls/issue.py @@ -14,7 +14,7 @@ from plane.app.views import ( IssueReactionViewSet, IssueRelationViewSet, IssueSubscriberViewSet, - IssueUserDisplayPropertyEndpoint, + ProjectUserDisplayPropertyEndpoint, IssueViewSet, LabelViewSet, BulkArchiveIssuesEndpoint, @@ -208,13 +208,13 @@ urlpatterns = [ name="project-issue-comment-reactions", ), ## End Comment Reactions - ## IssueUserProperty + ## ProjectUserProperty path( "workspaces//projects//user-properties/", - IssueUserDisplayPropertyEndpoint.as_view(), + ProjectUserDisplayPropertyEndpoint.as_view(), name="project-issue-display-properties", ), - ## IssueUserProperty End + ## ProjectUserProperty End ## Issue Archives path( "workspaces//projects//archived-issues/", diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 7a0e5cb3a2..88b739e4b3 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -114,7 +114,7 @@ from .asset.v2 import ( from .issue.base import ( IssueListEndpoint, IssueViewSet, - IssueUserDisplayPropertyEndpoint, + ProjectUserDisplayPropertyEndpoint, BulkDeleteIssuesEndpoint, DeletedIssuesListViewSet, IssuePaginatedViewSet, diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 7a5e7dddf6..89fd9bbda9 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -34,7 +34,7 @@ from plane.app.serializers import ( IssueDetailSerializer, IssueListDetailSerializer, IssueSerializer, - IssueUserPropertySerializer, + ProjectUserPropertySerializer, ) from plane.bgtasks.issue_activities_task import issue_activity from plane.bgtasks.issue_description_version_task import issue_description_version_task @@ -51,7 +51,7 @@ from plane.db.models import ( IssueReaction, IssueRelation, IssueSubscriber, - IssueUserProperty, + ProjectUserProperty, ModuleIssue, Project, ProjectMember, @@ -723,23 +723,33 @@ class IssueViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class IssueUserDisplayPropertyEndpoint(BaseAPIView): +class ProjectUserDisplayPropertyEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def patch(self, request, slug, project_id): - issue_property = IssueUserProperty.objects.get(user=request.user, project_id=project_id) + try: + issue_property = ProjectUserProperty.objects.get( + user=request.user, + project_id=project_id + ) + except ProjectUserProperty.DoesNotExist: + issue_property = ProjectUserProperty.objects.create( + user=request.user, + project_id=project_id + ) - issue_property.rich_filters = request.data.get("rich_filters", issue_property.rich_filters) - issue_property.filters = request.data.get("filters", issue_property.filters) - issue_property.display_filters = request.data.get("display_filters", issue_property.display_filters) - issue_property.display_properties = request.data.get("display_properties", issue_property.display_properties) - issue_property.save() - serializer = IssueUserPropertySerializer(issue_property) - return Response(serializer.data, status=status.HTTP_201_CREATED) + serializer = ProjectUserPropertySerializer( + issue_property, + data=request.data, + partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id): - issue_property, _ = IssueUserProperty.objects.get_or_create(user=request.user, project_id=project_id) - serializer = IssueUserPropertySerializer(issue_property) + issue_property, _ = ProjectUserProperty.objects.get_or_create(user=request.user, project_id=project_id) + serializer = ProjectUserPropertySerializer(issue_property) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py index 34e13d472d..e1d0c0c2a6 100644 --- a/apps/api/plane/app/views/project/base.py +++ b/apps/api/plane/app/views/project/base.py @@ -24,14 +24,15 @@ from plane.bgtasks.webhook_task import model_activity, webhook_activity from plane.db.models import ( UserFavorite, DeployBoard, + ProjectUserProperty, Intake, - IssueUserProperty, Project, ProjectIdentifier, ProjectMember, ProjectNetwork, State, DEFAULT_STATES, + UserFavorite, Workspace, WorkspaceMember, ) @@ -250,8 +251,6 @@ class ProjectViewSet(BaseViewSet): member=request.user, role=ROLE.ADMIN.value, ) - # Also create the issue property for the user - _ = IssueUserProperty.objects.create(project_id=serializer.data["id"], user=request.user) if serializer.data["project_lead"] is not None and str(serializer.data["project_lead"]) != str( request.user.id @@ -261,11 +260,6 @@ class ProjectViewSet(BaseViewSet): member_id=serializer.data["project_lead"], role=ROLE.ADMIN.value, ) - # Also create the issue property for the user - IssueUserProperty.objects.create( - project_id=serializer.data["id"], - user_id=serializer.data["project_lead"], - ) State.objects.bulk_create( [ diff --git a/apps/api/plane/app/views/project/invite.py b/apps/api/plane/app/views/project/invite.py index cc5b3f4b57..f4ea9ba41a 100644 --- a/apps/api/plane/app/views/project/invite.py +++ b/apps/api/plane/app/views/project/invite.py @@ -24,7 +24,7 @@ from plane.db.models import ( User, WorkspaceMember, Project, - IssueUserProperty, + ProjectUserProperty, ) from plane.db.models.project import ProjectNetwork from plane.utils.host import base_host @@ -160,9 +160,9 @@ class UserProjectInvitationsViewset(BaseViewSet): ignore_conflicts=True, ) - IssueUserProperty.objects.bulk_create( + ProjectUserProperty.objects.bulk_create( [ - IssueUserProperty( + ProjectUserProperty( project_id=project_id, user=request.user, workspace=workspace, @@ -220,7 +220,7 @@ class ProjectJoinEndpoint(BaseAPIView): if project_member is None: # Create a Project Member _ = ProjectMember.objects.create( - workspace_id=project_invite.workspace_id, + project_id=project_id, member=user, role=project_invite.role, ) diff --git a/apps/api/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py index 3ab7061e15..7c5e4f4f6f 100644 --- a/apps/api/plane/app/views/project/member.py +++ b/apps/api/plane/app/views/project/member.py @@ -1,6 +1,7 @@ # Third Party imports from rest_framework.response import Response from rest_framework import status +from django.db.models import Min # Module imports from .base import BaseViewSet, BaseAPIView @@ -13,7 +14,7 @@ from plane.app.serializers import ( from plane.app.permissions import WorkspaceUserPermission -from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember +from plane.db.models import Project, ProjectMember, ProjectUserProperty, WorkspaceMember from plane.bgtasks.project_add_user_email_task import project_add_user_email from plane.utils.host import base_host from plane.app.permissions.base import allow_permission, ROLE @@ -89,24 +90,23 @@ class ProjectMemberViewSet(BaseViewSet): # Update the roles of the existing members ProjectMember.objects.bulk_update(bulk_project_members, ["is_active", "role"], batch_size=100) - # Get the list of project members of the requested workspace with the given slug - project_members = ( - ProjectMember.objects.filter( + # Get the minimum sort_order for each member in the workspace + member_sort_orders = ( + ProjectUserProperty.objects.filter( workspace__slug=slug, - member_id__in=[member.get("member_id") for member in members], + user_id__in=[member.get("member_id") for member in members], ) - .values("member_id", "sort_order") - .order_by("sort_order") + .values("user_id") + .annotate(min_sort_order=Min("sort_order")) ) + # Convert to dictionary for easy lookup: {user_id: min_sort_order} + sort_order_map = {str(item["user_id"]): item["min_sort_order"] for item in member_sort_orders} # Loop through requested members for member in members: - # Get the sort orders of the member - sort_order = [ - project_member.get("sort_order") - for project_member in project_members - if str(project_member.get("member_id")) == str(member.get("member_id")) - ] + member_id = str(member.get("member_id")) + # Get the minimum sort_order for this member, or use default + min_sort_order = sort_order_map.get(member_id) # Create a new project member bulk_project_members.append( ProjectMember( @@ -114,22 +114,22 @@ class ProjectMemberViewSet(BaseViewSet): role=member.get("role", 5), project_id=project_id, workspace_id=project.workspace_id, - sort_order=(sort_order[0] - 10000 if len(sort_order) else 65535), ) ) # Create a new issue property bulk_issue_props.append( - IssueUserProperty( + ProjectUserProperty( user_id=member.get("member_id"), project_id=project_id, workspace_id=project.workspace_id, + sort_order=(min_sort_order - 10000 if min_sort_order is not None else 65535), ) ) # Bulk create the project members and issue properties project_members = ProjectMember.objects.bulk_create(bulk_project_members, batch_size=10, ignore_conflicts=True) - _ = IssueUserProperty.objects.bulk_create(bulk_issue_props, batch_size=10, ignore_conflicts=True) + _ = ProjectUserProperty.objects.bulk_create(bulk_issue_props, batch_size=10, ignore_conflicts=True) project_members = ProjectMember.objects.filter( project_id=project_id, diff --git a/apps/api/plane/bgtasks/workspace_seed_task.py b/apps/api/plane/bgtasks/workspace_seed_task.py index d1c15a345c..df69b1f4ac 100644 --- a/apps/api/plane/bgtasks/workspace_seed_task.py +++ b/apps/api/plane/bgtasks/workspace_seed_task.py @@ -21,7 +21,7 @@ from plane.db.models import ( WorkspaceMember, Project, ProjectMember, - IssueUserProperty, + ProjectUserProperty, State, Label, Issue, @@ -122,9 +122,9 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int, ) # Create issue user properties - IssueUserProperty.objects.bulk_create( + ProjectUserProperty.objects.bulk_create( [ - IssueUserProperty( + ProjectUserProperty( project=project, user_id=workspace_member["member_id"], workspace_id=workspace.id, diff --git a/apps/api/plane/db/management/commands/create_project_member.py b/apps/api/plane/db/management/commands/create_project_member.py index d9b46524c2..c04dbda7e7 100644 --- a/apps/api/plane/db/management/commands/create_project_member.py +++ b/apps/api/plane/db/management/commands/create_project_member.py @@ -8,7 +8,7 @@ from plane.db.models import ( WorkspaceMember, ProjectMember, Project, - IssueUserProperty, + ProjectUserProperty, ) @@ -47,27 +47,18 @@ class Command(BaseCommand): if not WorkspaceMember.objects.filter(workspace=project.workspace, member=user, is_active=True).exists(): raise CommandError("User not member in workspace") - # Get the smallest sort order - smallest_sort_order = ( - ProjectMember.objects.filter(workspace_id=project.workspace_id).order_by("sort_order").first() - ) - - if smallest_sort_order: - sort_order = smallest_sort_order.sort_order - 1000 - else: - sort_order = 65535 if ProjectMember.objects.filter(project=project, member=user).exists(): # Update the project member ProjectMember.objects.filter(project=project, member=user).update( - is_active=True, sort_order=sort_order, role=role + is_active=True, role=role ) else: # Create the project member - ProjectMember.objects.create(project=project, member=user, role=role, sort_order=sort_order) + ProjectMember.objects.create(project=project, member=user, role=role) # Issue Property - IssueUserProperty.objects.get_or_create(user=user, project=project) + ProjectUserProperty.objects.get_or_create(user=user, project=project) # Success message self.stdout.write(self.style.SUCCESS(f"User {user_email} added to project {project_id}")) diff --git a/apps/api/plane/db/migrations/0114_projectuserproperty_delete_issueuserproperty_and_more.py b/apps/api/plane/db/migrations/0114_projectuserproperty_delete_issueuserproperty_and_more.py new file mode 100644 index 0000000000..9a18fbafca --- /dev/null +++ b/apps/api/plane/db/migrations/0114_projectuserproperty_delete_issueuserproperty_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.22 on 2026-01-05 08:35 + +from django.db import migrations, models +import plane.db.models.project +import django.db.models.deletion +from django.conf import settings + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0113_webhook_version'), + ] + + operations = [ + migrations.AlterModelTable( + name='issueuserproperty', + table='project_user_properties', + ), + migrations.RenameModel( + old_name='IssueUserProperty', + new_name='ProjectUserProperty', + ), + migrations.AddField( + model_name='projectuserproperty', + name='preferences', + field=models.JSONField(default=plane.db.models.project.get_default_preferences), + ), + migrations.AddField( + model_name='projectuserproperty', + name='sort_order', + field=models.FloatField(default=65535), + ), + migrations.AlterModelOptions( + name='projectuserproperty', + options={'ordering': ('-created_at',), 'verbose_name': 'Project User Property', 'verbose_name_plural': 'Project User Properties'}, + ), + migrations.RemoveConstraint( + model_name='projectuserproperty', + name='issue_user_property_unique_user_project_when_deleted_at_null', + ), + migrations.AlterField( + model_name='projectuserproperty', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_property_user', to=settings.AUTH_USER_MODEL), + ), + migrations.AddConstraint( + model_name='projectuserproperty', + constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('user', 'project'), name='project_user_property_unique_user_project_when_deleted_at_null'), + ), + ] \ No newline at end of file diff --git a/apps/api/plane/db/migrations/0115_auto_20260105_0836.py b/apps/api/plane/db/migrations/0115_auto_20260105_0836.py new file mode 100644 index 0000000000..b9ac71d470 --- /dev/null +++ b/apps/api/plane/db/migrations/0115_auto_20260105_0836.py @@ -0,0 +1,51 @@ +# Generated by Django 4.2.22 on 2026-01-05 08:36 + +from django.db import migrations + +def move_issue_user_properties_to_project_user_properties(apps, schema_editor): + ProjectMember = apps.get_model('db', 'ProjectMember') + ProjectUserProperty = apps.get_model('db', 'ProjectUserProperty') + + # Get all project members + project_members = ProjectMember.objects.filter(deleted_at__isnull=True).values('member_id', 'project_id', 'preferences', 'sort_order') + + # create a mapping with consistent ordering + pm_dict = { + (pm['member_id'], pm['project_id']): pm + for pm in project_members + } + + # Get all project user properties + properties_to_update = [] + for projectuserproperty in ProjectUserProperty.objects.filter(deleted_at__isnull=True): + pm = pm_dict.get((projectuserproperty.user_id, projectuserproperty.project_id)) + if pm: + projectuserproperty.preferences = pm['preferences'] + projectuserproperty.sort_order = pm['sort_order'] + properties_to_update.append(projectuserproperty) + + ProjectUserProperty.objects.bulk_update(properties_to_update, ['preferences', 'sort_order'], batch_size=2000) + + + +def migrate_existing_api_tokens(apps, schema_editor): + APIToken = apps.get_model('db', 'APIToken') + + # Update all the existing non-service api tokens to not have a workspace + APIToken.objects.filter(is_service=False, user__is_bot=False).update( + workspace_id=None, + + ) + return + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0114_projectuserproperty_delete_issueuserproperty_and_more'), + ] + + operations = [ + migrations.RunPython(move_issue_user_properties_to_project_user_properties, reverse_code=migrations.RunPython.noop), + migrations.RunPython(migrate_existing_api_tokens, reverse_code=migrations.RunPython.noop), + ] diff --git a/apps/api/plane/db/models/__init__.py b/apps/api/plane/db/models/__init__.py index 41fd32bd55..5d7267c213 100644 --- a/apps/api/plane/db/models/__init__.py +++ b/apps/api/plane/db/models/__init__.py @@ -34,7 +34,6 @@ from .issue import ( IssueLabel, IssueLink, IssueMention, - IssueUserProperty, IssueReaction, IssueRelation, IssueSequence, @@ -54,6 +53,7 @@ from .project import ( ProjectMemberInvite, ProjectNetwork, ProjectPublicMember, + ProjectUserProperty, ) from .session import Session from .social_connection import SocialLoginConnection diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index 5f6ce051d2..68a4ae6dd0 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -526,36 +526,6 @@ class IssueComment(ChangeTrackerMixin, ProjectBaseModel): return str(self.issue) -class IssueUserProperty(ProjectBaseModel): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="issue_property_user", - ) - filters = models.JSONField(default=get_default_filters) - display_filters = models.JSONField(default=get_default_display_filters) - display_properties = models.JSONField(default=get_default_display_properties) - rich_filters = models.JSONField(default=dict) - - class Meta: - verbose_name = "Issue User Property" - verbose_name_plural = "Issue User Properties" - db_table = "issue_user_properties" - ordering = ("-created_at",) - unique_together = ["user", "project", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["user", "project"], - condition=Q(deleted_at__isnull=True), - name="issue_user_property_unique_user_project_when_deleted_at_null", - ) - ] - - def __str__(self): - """Return properties status of the issue""" - return str(self.user) - - class IssueLabel(ProjectBaseModel): issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="label_issue") label = models.ForeignKey("db.Label", on_delete=models.CASCADE, related_name="label_issue") diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index 173ed43854..16281025bb 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -12,7 +12,6 @@ from django.db.models import Q # Module imports from plane.db.mixins import AuditModel -# Module imports from .base import BaseModel ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest")) @@ -219,14 +218,20 @@ class ProjectMember(ProjectBaseModel): is_active = models.BooleanField(default=True) def save(self, *args, **kwargs): - if self._state.adding: - smallest_sort_order = ProjectMember.objects.filter( - workspace_id=self.project.workspace_id, member=self.member - ).aggregate(smallest=models.Min("sort_order"))["smallest"] + if self._state.adding and self.member: + # Get the minimum sort_order for this member in the workspace + min_sort_order_result = ProjectUserProperty.objects.filter( + workspace_id=self.project.workspace_id, user=self.member + ).aggregate(min_sort_order=models.Min("sort_order")) + min_sort_order = min_sort_order_result.get("min_sort_order") - # Project ordering - if smallest_sort_order is not None: - self.sort_order = smallest_sort_order - 10000 + # create project user property with project sort order + ProjectUserProperty.objects.create( + workspace_id=self.project.workspace_id, + project=self.project, + user=self.member, + sort_order=(min_sort_order - 10000 if min_sort_order is not None else 65535), + ) super(ProjectMember, self).save(*args, **kwargs) @@ -326,3 +331,37 @@ class ProjectPublicMember(ProjectBaseModel): verbose_name_plural = "Project Public Members" db_table = "project_public_members" ordering = ("-created_at",) + + +class ProjectUserProperty(ProjectBaseModel): + from .issue import get_default_filters, get_default_display_filters, get_default_display_properties + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="project_property_user", + ) + filters = models.JSONField(default=get_default_filters) + display_filters = models.JSONField(default=get_default_display_filters) + display_properties = models.JSONField(default=get_default_display_properties) + rich_filters = models.JSONField(default=dict) + preferences = models.JSONField(default=get_default_preferences) + sort_order = models.FloatField(default=65535) + + class Meta: + verbose_name = "Project User Property" + verbose_name_plural = "Project User Properties" + db_table = "project_user_properties" + ordering = ("-created_at",) + unique_together = ["user", "project", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["user", "project"], + condition=Q(deleted_at__isnull=True), + name="project_user_property_unique_user_project_when_deleted_at_null", + ) + ] + + def __str__(self): + """Return properties status of the project""" + return str(self.user) diff --git a/apps/api/plane/tests/contract/app/test_project_app.py b/apps/api/plane/tests/contract/app/test_project_app.py index 38b0f51f3b..9f05314cfe 100644 --- a/apps/api/plane/tests/contract/app/test_project_app.py +++ b/apps/api/plane/tests/contract/app/test_project_app.py @@ -6,7 +6,7 @@ from django.utils import timezone from plane.db.models import ( Project, ProjectMember, - IssueUserProperty, + ProjectUserProperty, State, WorkspaceMember, User, @@ -82,8 +82,8 @@ class TestProjectAPIPost(TestProjectBase): assert project_member.role == 20 # Administrator assert project_member.is_active is True - # Verify IssueUserProperty was created - assert IssueUserProperty.objects.filter(project=project, user=user).exists() + # Verify ProjectUserProperty was created + assert ProjectUserProperty.objects.filter(project=project, user=user).exists() # Verify default states were created states = State.objects.filter(project=project) @@ -116,8 +116,8 @@ class TestProjectAPIPost(TestProjectBase): project = Project.objects.get(name=project_data["name"]) assert ProjectMember.objects.filter(project=project, role=20).count() == 2 - # Verify both have IssueUserProperty - assert IssueUserProperty.objects.filter(project=project).count() == 2 + # Verify both have ProjectUserProperty + assert ProjectUserProperty.objects.filter(project=project).count() == 2 @pytest.mark.django_db def test_create_project_guest_forbidden(self, session_client, workspace): diff --git a/apps/web/core/components/navigation/use-tab-preferences.ts b/apps/web/core/components/navigation/use-tab-preferences.ts index 57a6100943..5edc2396ed 100644 --- a/apps/web/core/components/navigation/use-tab-preferences.ts +++ b/apps/web/core/components/navigation/use-tab-preferences.ts @@ -23,7 +23,7 @@ export type TTabPreferencesHook = { */ export const useTabPreferences = (workspaceSlug: string, projectId: string): TTabPreferencesHook => { const { - project: { getProjectMemberPreferences, updateProjectMemberPreferences }, + project: { getProjectUserProperties, updateProjectUserProperties }, } = useMember(); // const { projectUserInfo } = useUserPermissions(); const { data } = useUser(); @@ -33,21 +33,17 @@ export const useTabPreferences = (workspaceSlug: string, projectId: string): TTa const memberId = data?.id || null; // Get preferences from store - const storePreferences = getProjectMemberPreferences(projectId); + const storePreferences = getProjectUserProperties(projectId); + const defaultTab = storePreferences?.preferences?.navigation?.default_tab || DEFAULT_TAB_KEY; + const hideInMoreMenu = storePreferences?.preferences?.navigation?.hide_in_more_menu || []; // Convert store preferences to component format const tabPreferences: TTabPreferences = useMemo(() => { - if (storePreferences) { - return { - defaultTab: storePreferences.default_tab || DEFAULT_TAB_KEY, - hiddenTabs: storePreferences.hide_in_more_menu || [], - }; - } return { - defaultTab: DEFAULT_TAB_KEY, - hiddenTabs: [], + defaultTab, + hiddenTabs: hideInMoreMenu, }; - }, [storePreferences]); + }, [defaultTab, hideInMoreMenu]); const isLoading = !storePreferences && memberId !== null; @@ -55,11 +51,14 @@ export const useTabPreferences = (workspaceSlug: string, projectId: string): TTa * Update preferences via store */ const updatePreferences = async (newPreferences: TTabPreferences) => { - if (!memberId) return; - - await updateProjectMemberPreferences(workspaceSlug, projectId, memberId, { - default_tab: newPreferences.defaultTab, - hide_in_more_menu: newPreferences.hiddenTabs, + await updateProjectUserProperties(workspaceSlug, projectId, { + preferences: { + pages: storePreferences?.preferences?.pages || { block_display: false }, + navigation: { + default_tab: newPreferences.defaultTab, + hide_in_more_menu: newPreferences.hiddenTabs, + }, + }, }); }; @@ -77,6 +76,7 @@ export const useTabPreferences = (workspaceSlug: string, projectId: string): TTa title: "Success!", message: "Default tab updated successfully.", }); + return; }) .catch(() => { setToast({ diff --git a/apps/web/core/layouts/auth-layout/project-wrapper.tsx b/apps/web/core/layouts/auth-layout/project-wrapper.tsx index 6df49cbea5..fbbe1807cc 100644 --- a/apps/web/core/layouts/auth-layout/project-wrapper.tsx +++ b/apps/web/core/layouts/auth-layout/project-wrapper.tsx @@ -52,7 +52,7 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP const { initGantt } = useTimeLineChart(GANTT_TIMELINE_TYPE.MODULE); const { fetchViews } = useProjectView(); const { - project: { fetchProjectMembers, fetchProjectMemberPreferences }, + project: { fetchProjectMembers, fetchProjectUserProperties }, } = useMember(); const { fetchProjectStates, fetchProjectIntakeState } = useProjectState(); const { data: currentUserData } = useUser(); @@ -83,7 +83,7 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP // fetching project member preferences useSWR( currentUserData?.id ? PROJECT_MEMBER_PREFERENCES(projectId, currentProjectRole) : null, - currentUserData?.id ? () => fetchProjectMemberPreferences(workspaceSlug, projectId, currentUserData.id) : null, + currentUserData?.id ? () => fetchProjectUserProperties(workspaceSlug, projectId) : null, { revalidateIfStale: false, revalidateOnFocus: false } ); // fetching project labels diff --git a/apps/web/core/services/issue_filter.service.ts b/apps/web/core/services/issue_filter.service.ts index d6f67b108c..c81d7e4336 100644 --- a/apps/web/core/services/issue_filter.service.ts +++ b/apps/web/core/services/issue_filter.service.ts @@ -28,26 +28,6 @@ export class IssueFiltersService extends APIService { // }); // } - // project issue filters - async fetchProjectIssueFilters(workspaceSlug: string, projectId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-properties/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - async patchProjectIssueFilters( - workspaceSlug: string, - projectId: string, - data: Partial - ): Promise { - return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-properties/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - // epic issue filters async fetchProjectEpicFilters(workspaceSlug: string, projectId: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/epics-user-properties/`) diff --git a/apps/web/core/services/project/project-member.service.ts b/apps/web/core/services/project/project-member.service.ts index 04d62d1537..8c378afa41 100644 --- a/apps/web/core/services/project/project-member.service.ts +++ b/apps/web/core/services/project/project-member.service.ts @@ -1,12 +1,6 @@ // types import { API_BASE_URL } from "@plane/constants"; -import type { - IProjectBulkAddFormData, - IProjectMemberPreferencesFullResponse, - IProjectMemberPreferencesResponse, - IProjectMemberPreferencesUpdate, - TProjectMembership, -} from "@plane/types"; +import type { IProjectBulkAddFormData, TProjectMembership } from "@plane/types"; // services import { APIService } from "@/services/api.service"; @@ -71,31 +65,6 @@ export class ProjectMemberService extends APIService { throw error?.response?.data; }); } - - async getProjectMemberPreferences( - workspaceSlug: string, - projectId: string, - memberId: string - ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/preferences/member/${memberId}/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async updateProjectMemberPreferences( - workspaceSlug: string, - projectId: string, - memberId: string, - data: IProjectMemberPreferencesUpdate - ): Promise { - return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/preferences/member/${memberId}/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } } const projectMemberService = new ProjectMemberService(); diff --git a/apps/web/core/services/project/project.service.ts b/apps/web/core/services/project/project.service.ts index 5915c7608d..f4e29cecfc 100644 --- a/apps/web/core/services/project/project.service.ts +++ b/apps/web/core/services/project/project.service.ts @@ -1,6 +1,7 @@ import { API_BASE_URL } from "@plane/constants"; import type { GithubRepositoriesResponse, + IProjectUserPropertiesResponse, ISearchIssueResponse, TProjectAnalyticsCount, TProjectAnalyticsCountParams, @@ -90,14 +91,21 @@ export class ProjectService extends APIService { }); } - async setProjectView( + // User Properties + async getProjectUserProperties(workspaceSlug: string, projectId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-properties/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async updateProjectUserProperties( workspaceSlug: string, projectId: string, - data: { - sort_order?: number; - } - ): Promise { - await this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-views/`, data) + data: Partial + ): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-properties/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/apps/web/core/store/issue/project/filter.store.ts b/apps/web/core/store/issue/project/filter.store.ts index 1768606c5e..dbfc6817d3 100644 --- a/apps/web/core/store/issue/project/filter.store.ts +++ b/apps/web/core/store/issue/project/filter.store.ts @@ -16,12 +16,12 @@ import type { } from "@plane/types"; import { EIssuesStoreType } from "@plane/types"; import { handleIssueQueryParamsByLayout } from "@plane/utils"; -import { IssueFiltersService } from "@/services/issue_filter.service"; import type { IBaseIssueFilterStore } from "../helpers/issue-filter-helper.store"; import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers // types import type { IIssueRootStore } from "../root.store"; +import { ProjectService } from "@/services/project"; // constants // services @@ -56,7 +56,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj // root store rootIssueStore: IIssueRootStore; // services - issueFilterService; + projectService; constructor(_rootStore: IIssueRootStore) { super(); @@ -74,7 +74,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj // root store this.rootIssueStore = _rootStore; // services - this.issueFilterService = new IssueFiltersService(); + this.projectService = new ProjectService(); } get issueFilters() { @@ -129,7 +129,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj ); fetchFilters = async (workspaceSlug: string, projectId: string) => { - const _filters = await this.issueFilterService.fetchProjectIssueFilters(workspaceSlug, projectId); + const _filters = await this.projectService.getProjectUserProperties(workspaceSlug, projectId); const richFilters = _filters?.rich_filters; const displayFilters = this.computedDisplayFilters(_filters?.display_filters); @@ -176,7 +176,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj }); this.rootIssueStore.projectIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation"); - await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, { + await this.projectService.updateProjectUserProperties(workspaceSlug, projectId, { rich_filters: filters, }); } catch (error) { @@ -238,7 +238,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj this.rootIssueStore.projectIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation"); } - await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, { + await this.projectService.updateProjectUserProperties(workspaceSlug, projectId, { display_filters: _filters.displayFilters, }); @@ -258,7 +258,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj }); }); - await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, { + await this.projectService.updateProjectUserProperties(workspaceSlug, projectId, { display_properties: _filters.displayProperties, }); break; diff --git a/apps/web/core/store/member/project/base-project-member.store.ts b/apps/web/core/store/member/project/base-project-member.store.ts index eb135868aa..0a2bbeb356 100644 --- a/apps/web/core/store/member/project/base-project-member.store.ts +++ b/apps/web/core/store/member/project/base-project-member.store.ts @@ -6,14 +6,14 @@ import { EUserPermissions } from "@plane/constants"; import type { EUserProjectRoles, IProjectBulkAddFormData, - IProjectMemberNavigationPreferences, + IProjectUserPropertiesResponse, IUserLite, TProjectMembership, } from "@plane/types"; // plane web imports import type { RootStore } from "@/plane-web/store/root.store"; // services -import { ProjectMemberService } from "@/services/project"; +import { ProjectMemberService, ProjectService } from "@/services/project"; // store import type { IProjectStore } from "@/store/project/project.store"; import type { IRouterStore } from "@/store/router.store"; @@ -36,8 +36,8 @@ export interface IBaseProjectMemberStore { projectMemberMap: { [projectId: string]: Record; }; - projectMemberPreferencesMap: { - [projectId: string]: IProjectMemberNavigationPreferences; + projectUserPropertiesMap: { + [projectId: string]: IProjectUserPropertiesResponse; }; // filters store filters: IProjectMemberFiltersStore; @@ -48,25 +48,20 @@ export interface IBaseProjectMemberStore { getProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null; getProjectMemberIds: (projectId: string, includeGuestUsers: boolean) => string[] | null; getFilteredProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null; - getProjectMemberPreferences: (projectId: string) => IProjectMemberNavigationPreferences | null; + getProjectUserProperties: (projectId: string) => IProjectUserPropertiesResponse | null; // fetch actions fetchProjectMembers: ( workspaceSlug: string, projectId: string, clearExistingMembers?: boolean ) => Promise; - fetchProjectMemberPreferences: ( - workspaceSlug: string, - projectId: string, - memberId: string - ) => Promise; + fetchProjectUserProperties: (workspaceSlug: string, projectId: string) => Promise; // update actions - updateProjectMemberPreferences: ( + updateProjectUserProperties: ( workspaceSlug: string, projectId: string, - memberId: string, - preferences: IProjectMemberNavigationPreferences - ) => Promise; + data: Partial + ) => Promise; // bulk operation actions bulkAddMembersToProject: ( workspaceSlug: string, @@ -91,8 +86,8 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore projectMemberMap: { [projectId: string]: Record; } = {}; - projectMemberPreferencesMap: { - [projectId: string]: IProjectMemberNavigationPreferences; + projectUserPropertiesMap: { + [projectId: string]: IProjectUserPropertiesResponse; } = {}; // filters store filters: IProjectMemberFiltersStore; @@ -104,18 +99,19 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore rootStore: RootStore; // services projectMemberService; + projectService; constructor(_memberRoot: IMemberRootStore, _rootStore: RootStore) { makeObservable(this, { // observables projectMemberMap: observable, - projectMemberPreferencesMap: observable, + projectUserPropertiesMap: observable, // computed projectMemberIds: computed, // actions fetchProjectMembers: action, - fetchProjectMemberPreferences: action, - updateProjectMemberPreferences: action, + fetchProjectUserProperties: action, + updateProjectUserProperties: action, bulkAddMembersToProject: action, updateMemberRole: action, removeMemberFromProject: action, @@ -129,6 +125,7 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore this.filters = new ProjectMemberFiltersStore(); // services this.projectMemberService = new ProjectMemberService(); + this.projectService = new ProjectService(); } /** @@ -440,62 +437,53 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore * @description get project member preferences * @param projectId */ - getProjectMemberPreferences = computedFn( - (projectId: string): IProjectMemberNavigationPreferences | null => - this.projectMemberPreferencesMap[projectId] || null + getProjectUserProperties = computedFn( + (projectId: string): IProjectUserPropertiesResponse | null => this.projectUserPropertiesMap[projectId] || null ); /** * @description fetch project member preferences * @param workspaceSlug * @param projectId - * @param memberId + * @param data */ - fetchProjectMemberPreferences = async ( + fetchProjectUserProperties = async ( workspaceSlug: string, - projectId: string, - memberId: string - ): Promise => { - const response = await this.projectMemberService.getProjectMemberPreferences(workspaceSlug, projectId, memberId); - const preferences: IProjectMemberNavigationPreferences = { - default_tab: response.preferences.navigation.default_tab, - hide_in_more_menu: response.preferences.navigation.hide_in_more_menu || [], - }; + projectId: string + ): Promise => { + const response = await this.projectService.getProjectUserProperties(workspaceSlug, projectId); runInAction(() => { - set(this.projectMemberPreferencesMap, [projectId], preferences); + set(this.projectUserPropertiesMap, [projectId], response); }); - return preferences; + return response; }; /** * @description update project member preferences * @param workspaceSlug * @param projectId - * @param memberId - * @param preferences + * @param data */ - updateProjectMemberPreferences = async ( + updateProjectUserProperties = async ( workspaceSlug: string, projectId: string, - memberId: string, - preferences: IProjectMemberNavigationPreferences - ): Promise => { - const previousPreferences = this.projectMemberPreferencesMap[projectId]; + data: Partial + ): Promise => { + const previousProperties = this.projectUserPropertiesMap[projectId]; try { // Optimistically update the store runInAction(() => { - set(this.projectMemberPreferencesMap, [projectId], preferences); - }); - await this.projectMemberService.updateProjectMemberPreferences(workspaceSlug, projectId, memberId, { - navigation: preferences, + set(this.projectUserPropertiesMap, [projectId], data); }); + const response = await this.projectService.updateProjectUserProperties(workspaceSlug, projectId, data); + return response; } catch (error) { // Revert on error runInAction(() => { - if (previousPreferences) { - set(this.projectMemberPreferencesMap, [projectId], previousPreferences); + if (previousProperties) { + set(this.projectUserPropertiesMap, [projectId], previousProperties); } else { - unset(this.projectMemberPreferencesMap, [projectId]); + unset(this.projectUserPropertiesMap, [projectId]); } }); throw error; diff --git a/apps/web/core/store/project/project.store.ts b/apps/web/core/store/project/project.store.ts index 422fac379f..51f64c6a62 100644 --- a/apps/web/core/store/project/project.store.ts +++ b/apps/web/core/store/project/project.store.ts @@ -509,7 +509,7 @@ export class ProjectStore implements IProjectStore { runInAction(() => { set(this.projectMap, [projectId, "sort_order"], viewProps?.sort_order); }); - const response = await this.projectService.setProjectView(workspaceSlug, projectId, viewProps); + const response = await this.projectService.updateProjectUserProperties(workspaceSlug, projectId, viewProps); return response; } catch (error) { runInAction(() => { diff --git a/packages/types/src/project/projects.ts b/packages/types/src/project/projects.ts index 9da12b5703..4258cf725a 100644 --- a/packages/types/src/project/projects.ts +++ b/packages/types/src/project/projects.ts @@ -74,12 +74,6 @@ export interface IProjectLite { logo_props: TLogoProps; } -export type ProjectPreferences = { - pages: { - block_display: boolean; - }; -}; - export interface IProjectMap { [id: string]: IProject; } diff --git a/packages/types/src/view-props.ts b/packages/types/src/view-props.ts index aa90541a95..40f22e07ae 100644 --- a/packages/types/src/view-props.ts +++ b/packages/types/src/view-props.ts @@ -1,3 +1,4 @@ +import type { IProjectMemberNavigationPreferences } from "./project"; import type { TIssue } from "./issues/issue"; import type { LOGICAL_OPERATOR, TSupportedOperators } from "./rich-filters"; import type { CompleteOrEmpty } from "./utils"; @@ -194,6 +195,16 @@ export interface IIssueFiltersResponse { display_properties: IIssueDisplayProperties; } +export interface IProjectUserPropertiesResponse extends IIssueFiltersResponse { + sort_order: number; + preferences: { + pages: { + block_display: boolean; + }; + navigation: IProjectMemberNavigationPreferences; + }; +} + export interface IWorkspaceUserPropertiesResponse extends IIssueFiltersResponse { navigation_project_limit?: number; navigation_control_preference?: "ACCORDION" | "TABBED"; From b83d4609389b36bf4042e794942bc136598154e1 Mon Sep 17 00:00:00 2001 From: Vipin Chaudhary Date: Wed, 7 Jan 2026 15:05:14 +0530 Subject: [PATCH 02/63] [WIKI-826] chore: add unique id as key to logo selector (#8494) --- packages/editor/src/core/extensions/callout/block.tsx | 1 + packages/editor/src/core/extensions/callout/logo-selector.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/core/extensions/callout/block.tsx b/packages/editor/src/core/extensions/callout/block.tsx index e1ad8ca1bf..94fa5a8113 100644 --- a/packages/editor/src/core/extensions/callout/block.tsx +++ b/packages/editor/src/core/extensions/callout/block.tsx @@ -35,6 +35,7 @@ export function CustomCalloutBlock(props: CustomCalloutNodeViewProps) { }} > Date: Fri, 9 Jan 2026 04:03:41 +0530 Subject: [PATCH 03/63] [VPAT-50] chore(security): add X-Frame-Options header to nginx configuration to prevent clickjacking attacks (#8507) * [VPAT-50] chore(security): add X-Frame-Options header to nginx configuration to prevent clickjacking attacks * [SECURITY] chore: enhance nginx configuration with additional security headers --- apps/admin/nginx/nginx.conf | 6 ++++++ apps/web/nginx/nginx.conf | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/apps/admin/nginx/nginx.conf b/apps/admin/nginx/nginx.conf index 243aebff54..0fd4a192ae 100644 --- a/apps/admin/nginx/nginx.conf +++ b/apps/admin/nginx/nginx.conf @@ -20,6 +20,12 @@ http { server { listen 3000; + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-XSS-Protection "1; mode=block" always; + location / { root /usr/share/nginx/html; index index.html index.htm; diff --git a/apps/web/nginx/nginx.conf b/apps/web/nginx/nginx.conf index 160fcb9be9..34e07ba4be 100644 --- a/apps/web/nginx/nginx.conf +++ b/apps/web/nginx/nginx.conf @@ -20,6 +20,12 @@ http { server { listen 3000; + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-XSS-Protection "1; mode=block" always; + location / { root /usr/share/nginx/html; index index.html index.htm; From 5f3f9d2623b09899002a7b6841fc6f45f8a2d7eb Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:36:45 +0530 Subject: [PATCH 04/63] chore: updated migration file name (#8515) --- .../{0115_auto_20260105_0836.py => 0115_auto_20260105_1406.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/api/plane/db/migrations/{0115_auto_20260105_0836.py => 0115_auto_20260105_1406.py} (100%) diff --git a/apps/api/plane/db/migrations/0115_auto_20260105_0836.py b/apps/api/plane/db/migrations/0115_auto_20260105_1406.py similarity index 100% rename from apps/api/plane/db/migrations/0115_auto_20260105_0836.py rename to apps/api/plane/db/migrations/0115_auto_20260105_1406.py From 8399f64beef3f7c2d2a83dedab8d219355558195 Mon Sep 17 00:00:00 2001 From: sriramveeraghanta Date: Fri, 9 Jan 2026 14:43:36 +0530 Subject: [PATCH 05/63] chore(deps): react router upgraded --- apps/admin/package.json | 1 - apps/space/package.json | 1 - apps/web/package.json | 1 - package.json | 3 +- packages/codemods/instructions.md | 37 +++++++--- pnpm-lock.yaml | 114 +++++++++++------------------- pnpm-workspace.yaml | 3 +- 7 files changed, 72 insertions(+), 88 deletions(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index 25f0076f63..96e7ad24a3 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -45,7 +45,6 @@ "react-dom": "catalog:", "react-hook-form": "7.51.5", "react-router": "catalog:", - "react-router-dom": "catalog:", "serve": "14.2.5", "swr": "catalog:", "uuid": "catalog:" diff --git a/apps/space/package.json b/apps/space/package.json index f1fef4b763..5cf9f734f1 100644 --- a/apps/space/package.json +++ b/apps/space/package.json @@ -50,7 +50,6 @@ "react-hook-form": "7.51.5", "react-popper": "^2.3.0", "react-router": "catalog:", - "react-router-dom": "catalog:", "swr": "catalog:", "uuid": "catalog:" }, diff --git a/apps/web/package.json b/apps/web/package.json index b738271348..1f27048ac4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -67,7 +67,6 @@ "react-pdf-html": "^2.1.2", "react-popper": "^2.3.0", "react-router": "catalog:", - "react-router-dom": "catalog:", "recharts": "^2.12.7", "serve": "14.2.5", "smooth-scroll-into-view-if-needed": "^2.0.2", diff --git a/package.json b/package.json index 108487a482..366ecd064b 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,8 @@ "prosemirror-view": "1.40.0", "@types/express": "4.17.23", "typescript": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "qs": "6.14.1" }, "onlyBuiltDependencies": [ "@sentry/cli", diff --git a/packages/codemods/instructions.md b/packages/codemods/instructions.md index 4848c203a3..18212ba0c0 100644 --- a/packages/codemods/instructions.md +++ b/packages/codemods/instructions.md @@ -62,7 +62,9 @@ Retrieves variable declarators from the current collection. - **Parameters**: `callback` (Function) - **Example**: ```javascript - const variableDeclarators = j.find(j.Identifier).getVariableDeclarators((path) => path.value.name); + const variableDeclarators = j + .find(j.Identifier) + .getVariableDeclarators((path) => path.value.name); ``` #### `findVariableDeclarators` @@ -82,7 +84,9 @@ Filters nodes based on a predicate function. - **Parameters**: `predicate` (Function) - **Example**: ```javascript - const constDeclarations = j.find(j.VariableDeclaration).filter((path) => path.node.kind === "const"); + const constDeclarations = j + .find(j.VariableDeclaration) + .filter((path) => path.node.kind === "const"); ``` #### `forEach` @@ -104,7 +108,9 @@ Checks if at least one element in the collection passes the test. - **Parameters**: `callback` (Function) - **Example**: ```javascript - const hasVariableA = root.find(j.VariableDeclarator).some((path) => path.node.id.name === "a"); + const hasVariableA = root + .find(j.VariableDeclarator) + .some((path) => path.node.id.name === "a"); ``` #### `every` @@ -114,7 +120,9 @@ Checks if all elements in the collection pass the test. - **Parameters**: `callback` (Function) - **Example**: ```javascript - const allAreConst = root.find(j.VariableDeclaration).every((path) => path.node.kind === "const"); + const allAreConst = root + .find(j.VariableDeclaration) + .every((path) => path.node.kind === "const"); ``` #### `map` @@ -124,7 +132,9 @@ Maps each node in the collection to a new value. - **Parameters**: `callback` (Function) - **Example**: ```javascript - const variableNames = j.find(j.VariableDeclaration).map((path) => path.node.declarations.map((decl) => decl.id.name)); + const variableNames = j + .find(j.VariableDeclaration) + .map((path) => path.node.declarations.map((decl) => decl.id.name)); ``` #### `size` @@ -207,7 +217,10 @@ Checks if the node in the collection is of a specific type. - **Parameters**: `type` (String) - **Example**: ```javascript - const isVariableDeclarator = root.find(j.VariableDeclarator).at(0).isOfType("VariableDeclarator"); + const isVariableDeclarator = root + .find(j.VariableDeclarator) + .at(0) + .isOfType("VariableDeclarator"); ``` ### Node Transformation APIs @@ -219,7 +232,9 @@ Replaces the current node(s) with a new node. - **Parameters**: `newNode` (Node or Function) - **Example**: ```javascript - j.find(j.Identifier).replaceWith((path) => j.identifier(path.node.name.toUpperCase())); + j.find(j.Identifier).replaceWith((path) => + j.identifier(path.node.name.toUpperCase()) + ); ``` #### `insertBefore` @@ -229,7 +244,9 @@ Inserts a node before the current node. - **Parameters**: `newNode` (Node) - **Example**: ```javascript - j.find(j.FunctionDeclaration).insertBefore(j.expressionStatement(j.stringLiteral("Inserted before"))); + j.find(j.FunctionDeclaration).insertBefore( + j.expressionStatement(j.stringLiteral("Inserted before")) + ); ``` #### `insertAfter` @@ -239,7 +256,9 @@ Inserts a node after the current node. - **Parameters**: `newNode` (Node) - **Example**: ```javascript - j.find(j.FunctionDeclaration).insertAfter(j.expressionStatement(j.stringLiteral("Inserted after"))); + j.find(j.FunctionDeclaration).insertAfter( + j.expressionStatement(j.stringLiteral("Inserted after")) + ); ``` #### `remove` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a4b33c52a..dcd526473c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,11 +82,8 @@ catalogs: specifier: 18.3.1 version: 18.3.1 react-router: - specifier: 7.9.5 - version: 7.9.5 - react-router-dom: - specifier: 7.9.5 - version: 7.9.5 + specifier: 7.12.0 + version: 7.12.0 swr: specifier: 2.2.4 version: 2.2.4 @@ -115,6 +112,7 @@ overrides: '@types/express': 4.17.23 typescript: 5.8.3 vite: 7.1.11 + qs: 6.14.1 importers: @@ -227,10 +225,10 @@ importers: version: link:../../packages/utils '@react-router/node': specifier: 'catalog:' - version: 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) + version: 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) '@sentry/react-router': specifier: 'catalog:' - version: 10.27.0(@react-router/node@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 10.27.0(@react-router/node@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@tanstack/react-virtual': specifier: ^3.13.12 version: 3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -269,10 +267,7 @@ importers: version: 7.51.5(react@18.3.1) react-router: specifier: 'catalog:' - version: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-router-dom: - specifier: 'catalog:' - version: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) serve: specifier: 14.2.5 version: 14.2.5 @@ -294,7 +289,7 @@ importers: version: link:../../packages/typescript-config '@react-router/dev': specifier: 'catalog:' - version: 7.9.5(@react-router/serve@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1) + version: 7.9.5(@react-router/serve@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1) '@types/lodash-es': specifier: 'catalog:' version: 4.17.12 @@ -475,13 +470,13 @@ importers: version: 2.11.8 '@react-router/node': specifier: 'catalog:' - version: 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) + version: 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) '@react-router/serve': specifier: 'catalog:' - version: 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) + version: 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) '@sentry/react-router': specifier: 'catalog:' - version: 10.27.0(@react-router/node@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 10.27.0(@react-router/node@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) axios: specifier: 'catalog:' version: 1.12.0 @@ -529,10 +524,7 @@ importers: version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router: specifier: 'catalog:' - version: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-router-dom: - specifier: 'catalog:' - version: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) swr: specifier: 'catalog:' version: 2.2.4(react@18.3.1) @@ -551,7 +543,7 @@ importers: version: link:../../packages/typescript-config '@react-router/dev': specifier: 'catalog:' - version: 7.9.5(@react-router/serve@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1) + version: 7.9.5(@react-router/serve@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1) '@tailwindcss/typography': specifier: 0.5.19 version: 0.5.19 @@ -644,10 +636,10 @@ importers: version: 3.4.5(react@18.3.1) '@react-router/node': specifier: 'catalog:' - version: 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) + version: 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) '@sentry/react-router': specifier: 'catalog:' - version: 10.27.0(@react-router/node@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 10.27.0(@react-router/node@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -728,10 +720,7 @@ importers: version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router: specifier: 'catalog:' - version: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-router-dom: - specifier: 'catalog:' - version: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) recharts: specifier: ^2.12.7 version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -762,7 +751,7 @@ importers: version: link:../../packages/typescript-config '@react-router/dev': specifier: 'catalog:' - version: 7.9.5(@react-router/serve@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1) + version: 7.9.5(@react-router/serve@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1) '@tailwindcss/typography': specifier: 0.5.19 version: 0.5.19 @@ -7871,12 +7860,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} - engines: {node: '>=0.6'} - - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} quansync@0.2.11: @@ -8020,15 +8005,8 @@ packages: '@types/react': optional: true - react-router-dom@7.9.5: - resolution: {integrity: sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==} - engines: {node: '>=20.0.0'} - peerDependencies: - react: '>=18' - react-dom: '>=18' - - react-router@7.9.5: - resolution: {integrity: sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==} + react-router@7.12.0: + resolution: {integrity: sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -11005,7 +10983,7 @@ snapshots: '@react-pdf/primitives': 4.1.1 '@react-pdf/stylesheet': 6.1.0 - '@react-router/dev@7.9.5(@react-router/serve@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1)': + '@react-router/dev@7.9.5(@react-router/serve@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(@types/node@22.12.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.43.1)(typescript@5.8.3)(vite@7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1)': dependencies: '@babel/core': 7.28.4 '@babel/generator': 7.28.5 @@ -11015,7 +10993,7 @@ snapshots: '@babel/traverse': 7.28.4 '@babel/types': 7.28.5 '@npmcli/package-json': 4.0.1 - '@react-router/node': 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) + '@react-router/node': 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) '@remix-run/node-fetch-server': 0.9.0 arg: 5.0.2 babel-dead-code-elimination: 1.0.10 @@ -11031,14 +11009,14 @@ snapshots: picocolors: 1.1.1 prettier: 3.7.4 react-refresh: 0.14.2 - react-router: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-router: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) semver: 7.7.3 tinyglobby: 0.2.15 valibot: 1.2.0(typescript@5.8.3) vite: 7.1.11(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1) vite-node: 3.2.4(@types/node@22.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.43.1)(yaml@2.8.1) optionalDependencies: - '@react-router/serve': 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) + '@react-router/serve': 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: - '@types/node' @@ -11056,31 +11034,31 @@ snapshots: - tsx - yaml - '@react-router/express@7.9.5(express@4.22.0)(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)': + '@react-router/express@7.9.5(express@4.22.0)(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)': dependencies: - '@react-router/node': 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) + '@react-router/node': 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) express: 4.22.0 - react-router: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-router: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) optionalDependencies: typescript: 5.8.3 - '@react-router/node@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)': + '@react-router/node@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)': dependencies: '@mjackson/node-fetch-server': 0.2.0 - react-router: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-router: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) optionalDependencies: typescript: 5.8.3 - '@react-router/serve@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)': + '@react-router/serve@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3)': dependencies: '@mjackson/node-fetch-server': 0.2.0 - '@react-router/express': 7.9.5(express@4.22.0)(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) - '@react-router/node': 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) + '@react-router/express': 7.9.5(express@4.22.0)(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) + '@react-router/node': 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) compression: 1.8.1 express: 4.22.0 get-port: 5.1.1 morgan: 1.10.1 - react-router: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-router: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) source-map-support: 0.5.21 transitivePeerDependencies: - supports-color @@ -11378,13 +11356,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@sentry/react-router@10.27.0(@react-router/node@7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@sentry/react-router@10.27.0(@react-router/node@7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3))(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.37.0 - '@react-router/node': 7.9.5(react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) + '@react-router/node': 7.9.5(react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.8.3) '@sentry/browser': 10.27.0 '@sentry/cli': 2.58.2 '@sentry/core': 10.27.0 @@ -11393,7 +11371,7 @@ snapshots: '@sentry/vite-plugin': 4.6.0 glob: 11.1.0 react: 18.3.1 - react-router: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-router: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) transitivePeerDependencies: - encoding - supports-color @@ -13180,7 +13158,7 @@ snapshots: http-errors: 2.0.0 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.13.0 + qs: 6.14.1 raw-body: 2.5.2 type-is: 1.6.18 unpipe: 1.0.0 @@ -14357,7 +14335,7 @@ snapshots: parseurl: 1.3.3 path-to-regexp: 0.1.12 proxy-addr: 2.0.7 - qs: 6.14.0 + qs: 6.14.1 range-parser: 1.2.1 safe-buffer: 5.2.1 send: 0.19.0 @@ -16746,11 +16724,7 @@ snapshots: punycode@2.3.1: {} - qs@6.13.0: - dependencies: - side-channel: 1.1.0 - - qs@6.14.0: + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -16945,13 +16919,7 @@ snapshots: optionalDependencies: '@types/react': 18.3.11 - react-router-dom@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-router: 7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - - react-router@7.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: cookie: 1.0.2 react: 18.3.1 @@ -18136,7 +18104,7 @@ snapshots: url@0.11.4: dependencies: punycode: 1.4.1 - qs: 6.14.0 + qs: 6.14.1 use-callback-ref@1.3.3(@types/react@18.3.11)(react@18.3.1): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b85c8612e1..4d355526d0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,8 +30,7 @@ catalog: mobx-utils: 6.0.8 mobx: 6.12.0 react-dom: 18.3.1 - react-router-dom: 7.9.5 - react-router: 7.9.5 + react-router: 7.12.0 react: 18.3.1 swr: 2.2.4 tsdown: 0.16.0 From fa1b4a102a94a5f84d2d2fe883af115aaa771c0a Mon Sep 17 00:00:00 2001 From: Sangeetha Date: Thu, 15 Jan 2026 14:25:31 +0530 Subject: [PATCH 06/63] [WEB-5890] migration: added getting_started_checklist, tips, explored_feature fields on the workspace member table (#8489) * migration: added getting_started_checklist and tips field * fix: remove defaults and added explored_features field * fix: added user table migration --- ...kspacemember_explored_features_and_more.py | 38 +++++++++++++++++++ apps/api/plane/db/models/user.py | 10 ++++- apps/api/plane/db/models/workspace.py | 3 ++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 apps/api/plane/db/migrations/0116_workspacemember_explored_features_and_more.py diff --git a/apps/api/plane/db/migrations/0116_workspacemember_explored_features_and_more.py b/apps/api/plane/db/migrations/0116_workspacemember_explored_features_and_more.py new file mode 100644 index 0000000000..38e231e0eb --- /dev/null +++ b/apps/api/plane/db/migrations/0116_workspacemember_explored_features_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.27 on 2026-01-13 10:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0115_auto_20260105_1406'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='notification_view_mode', + field=models.CharField(choices=[('full', 'Full'), ('compact', 'Compact')], default='full', max_length=255), + ), + migrations.AddField( + model_name='user', + name='is_password_reset_required', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='workspacemember', + name='explored_features', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='workspacemember', + name='getting_started_checklist', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='workspacemember', + name='tips', + field=models.JSONField(default=dict), + ), + ] diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py index b0f571be9c..cae98d98d2 100644 --- a/apps/api/plane/db/models/user.py +++ b/apps/api/plane/db/models/user.py @@ -84,7 +84,7 @@ class User(AbstractBaseUser, PermissionsMixin): is_staff = models.BooleanField(default=False) is_email_verified = models.BooleanField(default=False) is_password_autoset = models.BooleanField(default=False) - + is_password_reset_required = models.BooleanField(default=False) # random token generated token = models.CharField(max_length=64, blank=True) @@ -192,6 +192,10 @@ class Profile(TimeAuditModel): FRIDAY = 5 SATURDAY = 6 + class NotificationViewMode(models.TextChoices): + FULL = "full", "Full" + COMPACT = "compact", "Compact" + START_OF_THE_WEEK_CHOICES = ( (SUNDAY, "Sunday"), (MONDAY, "Monday"), @@ -221,7 +225,9 @@ class Profile(TimeAuditModel): billing_address = models.JSONField(null=True) has_billing_address = models.BooleanField(default=False) company_name = models.CharField(max_length=255, blank=True) - + notification_view_mode = models.CharField( + max_length=255, choices=NotificationViewMode.choices, default=NotificationViewMode.FULL + ) is_smooth_cursor_enabled = models.BooleanField(default=False) # mobile is_mobile_onboarded = models.BooleanField(default=False) diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py index 9690168a11..fcd34c7b5b 100644 --- a/apps/api/plane/db/models/workspace.py +++ b/apps/api/plane/db/models/workspace.py @@ -214,6 +214,9 @@ class WorkspaceMember(BaseModel): default_props = models.JSONField(default=get_default_props) issue_props = models.JSONField(default=get_issue_props) is_active = models.BooleanField(default=True) + getting_started_checklist = models.JSONField(default=dict) + tips = models.JSONField(default=dict) + explored_features = models.JSONField(default=dict) class Meta: unique_together = ["workspace", "member", "deleted_at"] From f783447796de3846e2db1a7391be9184c62d49d0 Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com> Date: Mon, 19 Jan 2026 20:41:00 +0530 Subject: [PATCH 07/63] [WEB-5907] fix: magic code sign-in at Space app. #8552 --- apps/space/core/components/account/auth-forms/auth-root.tsx | 5 +++-- apps/space/core/types/auth.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/space/core/components/account/auth-forms/auth-root.tsx b/apps/space/core/components/account/auth-forms/auth-root.tsx index e313cce893..4c54e947c3 100644 --- a/apps/space/core/components/account/auth-forms/auth-root.tsx +++ b/apps/space/core/components/account/auth-forms/auth-root.tsx @@ -98,7 +98,7 @@ export const AuthRoot = observer(function AuthRoot() { } if (currentAuthMode === EAuthModes.SIGN_IN) { - if (response.is_password_autoset && isSMTPConfigured && isMagicLoginEnabled) { + if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") { setAuthStep(EAuthSteps.UNIQUE_CODE); generateEmailUniqueCode(data.email); } else if (isEmailPasswordEnabled) { @@ -109,7 +109,7 @@ export const AuthRoot = observer(function AuthRoot() { setErrorInfo(errorhandler); } } else { - if (isSMTPConfigured && isMagicLoginEnabled) { + if (isSMTPConfigured && isMagicLoginEnabled && response.status === "MAGIC_CODE") { setAuthStep(EAuthSteps.UNIQUE_CODE); generateEmailUniqueCode(data.email); } else if (isEmailPasswordEnabled) { @@ -119,6 +119,7 @@ export const AuthRoot = observer(function AuthRoot() { setErrorInfo(errorhandler); } } + return; }) .catch((error) => { const errorhandler = authErrorHandler(error?.error_code?.toString(), data?.email || undefined); diff --git a/apps/space/core/types/auth.ts b/apps/space/core/types/auth.ts index 19a616871e..45e501ba4f 100644 --- a/apps/space/core/types/auth.ts +++ b/apps/space/core/types/auth.ts @@ -19,6 +19,7 @@ export interface IEmailCheckData { } export interface IEmailCheckResponse { + status: "MAGIC_CODE" | "CREDENTIAL"; is_password_autoset: boolean; existing: boolean; } From 3de76206b550cfc5cc8fd8a0368b2487803c0db2 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Mon, 19 Jan 2026 20:41:50 +0530 Subject: [PATCH 08/63] [WIKI-735] fix: table insert handle z-index #8545 --- packages/editor/src/styles/table.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/styles/table.css b/packages/editor/src/styles/table.css index 0e706f41fb..5066204d40 100644 --- a/packages/editor/src/styles/table.css +++ b/packages/editor/src/styles/table.css @@ -160,7 +160,7 @@ opacity: 0; pointer-events: none; outline: none; - z-index: 10; + z-index: 9; transition: all 0.2s ease; &:hover { From f7debcde79ff89e1192a7ed9d25b12d2ac0af54c Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Mon, 19 Jan 2026 20:42:44 +0530 Subject: [PATCH 09/63] [WEB-5898] chore: update tailwind config #8516 --- packages/tailwind-config/variables.css | 108 +++++++++++++++++-------- 1 file changed, 75 insertions(+), 33 deletions(-) diff --git a/packages/tailwind-config/variables.css b/packages/tailwind-config/variables.css index bab1f015b8..2a3fed91b4 100644 --- a/packages/tailwind-config/variables.css +++ b/packages/tailwind-config/variables.css @@ -321,9 +321,9 @@ --txt-link-primary-hover: var(--brand-900); --txt-link-secondary: var(--neutral-900); /* Label colors */ - --label-indigo-bg: var(--extended-color-indigo-100); + --label-indigo-bg: var(--extended-color-indigo-50); --label-indigo-bg-strong: var(--extended-color-indigo-700); - --label-indigo-hover: var(--extended-color-indigo-300); + --label-indigo-hover: var(--extended-color-indigo-100); --label-indigo-icon: var(--extended-color-indigo-700); --label-indigo-text: var(--extended-color-indigo-700); --label-indigo-border: var(--extended-color-indigo-700); @@ -331,7 +331,7 @@ --label-emerald-bg: var(--extended-color-emerald-50); --label-emerald-bg-strong: var(--extended-color-emerald-600); --label-emerald-hover: var(--extended-color-emerald-200); - --label-emerald-icon: var(--extended-color-emerald-600); + --label-emerald-icon: var(--extended-color-emerald-800); --label-emerald-text: var(--extended-color-emerald-800); --label-emerald-border: var(--extended-color-emerald-800); --label-emerald-focus: var(--extended-color-emerald-700); @@ -352,10 +352,31 @@ --label-yellow-bg: var(--extended-color-yellow-50); --label-yellow-bg-strong: var(--extended-color-yellow-600); --label-yellow-hover: var(--extended-color-yellow-100); - --label-yellow-icon: var(--extended-color-yellow-600); - --label-yellow-text: var(--extended-color-yellow-600); + --label-yellow-icon: var(--extended-color-yellow-700); + --label-yellow-text: var(--extended-color-yellow-700); --label-yellow-border: var(--extended-color-yellow-600); --label-yellow-focus: var(--extended-color-yellow-400); + --label-orange-bg: var(--extended-color-orange-50); + --label-orange-bg-strong: var(--extended-color-orange-600); + --label-orange-hover: var(--extended-color-orange-100); + --label-orange-icon: var(--extended-color-orange-600); + --label-orange-text: var(--extended-color-orange-600); + --label-orange-border: var(--extended-color-orange-600); + --label-orange-focus: var(--extended-color-orange-300); + --label-pink-bg: var(--extended-color-pink-50); + --label-pink-bg-strong: var(--extended-color-pink-600); + --label-pink-hover: var(--extended-color-pink-100); + --label-pink-icon: var(--extended-color-pink-600); + --label-pink-text: var(--extended-color-pink-600); + --label-pink-border: var(--extended-color-pink-600); + --label-pink-focus: var(--extended-color-pink-400); + --label-purple-bg: var(--extended-color-purple-50); + --label-purple-bg-strong: var(--extended-color-purple-500); + --label-purple-hover: var(--extended-color-purple-100); + --label-purple-icon: var(--extended-color-purple-500); + --label-purple-text: var(--extended-color-purple-600); + --label-purple-border: var(--extended-color-purple-600); + --label-purple-focus: var(--extended-color-purple-300); /* Illustration colors */ --illustration-fill-primary: var(--neutral-white); --illustration-fill-secondary: var(--neutral-200); @@ -550,41 +571,62 @@ --txt-link-primary-hover: var(--brand-700); --txt-link-secondary: var(--neutral-1100); /* Label colors */ - --label-indigo-bg: var(--extended-color-indigo-800); + --label-indigo-bg: var(--extended-color-indigo-900); --label-indigo-bg-strong: var(--extended-color-indigo-500); - --label-indigo-hover: var(--extended-color-indigo-700); - --label-indigo-icon: var(--extended-color-indigo-500); - --label-indigo-text: var(--extended-color-indigo-500); - --label-indigo-border: var(--extended-color-indigo-500); + --label-indigo-hover: var(--extended-color-indigo-800); + --label-indigo-icon: var(--extended-color-indigo-400); + --label-indigo-text: var(--extended-color-indigo-400); + --label-indigo-border: var(--extended-color-indigo-400); --label-indigo-focus: var(--extended-color-indigo-400); - --label-emerald-bg: var(--extended-color-emerald-700); + --label-emerald-bg: var(--extended-color-emerald-950); --label-emerald-bg-strong: var(--extended-color-emerald-600); - --label-emerald-hover: var(--extended-color-emerald-800); - --label-emerald-icon: var(--extended-color-emerald-600); + --label-emerald-hover: var(--extended-color-emerald-900); + --label-emerald-icon: var(--extended-color-emerald-400); --label-emerald-text: var(--extended-color-emerald-400); --label-emerald-border: var(--extended-color-emerald-400); --label-emerald-focus: var(--extended-color-emerald-700); - --label-grey-bg: var(--extended-color-grey-800); + --label-grey-bg: var(--extended-color-grey-900); --label-grey-bg-strong: var(--extended-color-grey-500); - --label-grey-hover: var(--extended-color-grey-700); - --label-grey-icon: var(--extended-color-grey-500); - --label-grey-text: var(--extended-color-grey-500); - --label-grey-border: var(--extended-color-grey-500); + --label-grey-hover: var(--extended-color-grey-800); + --label-grey-icon: var(--extended-color-grey-400); + --label-grey-text: var(--extended-color-grey-400); + --label-grey-border: var(--extended-color-grey-400); --label-grey-focus: var(--extended-color-grey-400); - --label-crimson-bg: var(--extended-color-crimson-800); + --label-crimson-bg: var(--extended-color-crimson-950); --label-crimson-bg-strong: var(--extended-color-crimson-500); - --label-crimson-hover: var(--extended-color-crimson-700); - --label-crimson-icon: var(--extended-color-crimson-500); - --label-crimson-text: var(--extended-color-crimson-500); - --label-crimson-border: var(--extended-color-crimson-500); + --label-crimson-hover: var(--extended-color-crimson-900); + --label-crimson-icon: var(--extended-color-crimson-400); + --label-crimson-text: var(--extended-color-crimson-400); + --label-crimson-border: var(--extended-color-crimson-400); --label-crimson-focus: var(--extended-color-crimson-400); - --label-yellow-bg: var(--extended-color-yellow-900); + --label-yellow-bg: var(--extended-color-yellow-950); --label-yellow-bg-strong: var(--extended-color-yellow-500); - --label-yellow-hover: var(--extended-color-yellow-800); + --label-yellow-hover: var(--extended-color-yellow-900); --label-yellow-icon: var(--extended-color-yellow-500); --label-yellow-text: var(--extended-color-yellow-500); --label-yellow-border: var(--extended-color-yellow-500); --label-yellow-focus: var(--extended-color-yellow-400); + --label-orange-bg: var(--extended-color-orange-950); + --label-orange-bg-strong: var(--extended-color-orange-400); + --label-orange-hover: var(--extended-color-orange-900); + --label-orange-icon: var(--extended-color-orange-300); + --label-orange-text: var(--extended-color-orange-300); + --label-orange-border: var(--extended-color-orange-300); + --label-orange-focus: var(--extended-color-orange-300); + --label-pink-bg: var(--extended-color-pink-900); + --label-pink-bg-strong: var(--extended-color-pink-500); + --label-pink-hover: var(--extended-color-pink-800); + --label-pink-icon: var(--extended-color-pink-400); + --label-pink-text: var(--extended-color-pink-400); + --label-pink-border: var(--extended-color-pink-400); + --label-pink-focus: var(--extended-color-pink-400); + --label-purple-bg: var(--extended-color-purple-900); + --label-purple-bg-strong: var(--extended-color-purple-400); + --label-purple-hover: var(--extended-color-purple-800); + --label-purple-icon: var(--extended-color-purple-400); + --label-purple-text: var(--extended-color-purple-400); + --label-purple-border: var(--extended-color-purple-400); + --label-purple-focus: var(--extended-color-purple-300); /* Illustration colors */ --illustration-fill-primary: var(--neutral-400); --illustration-fill-secondary: var(--neutral-500); @@ -1059,42 +1101,42 @@ --text-h4-bold--letter-spacing: var(--tracking-default); --text-h4-bold--font-weight: var(--font-weight-bold); - --text-h5-regular: var(--text-16); + --text-h5-regular: var(--text-18); --text-h5-regular--line-height: 1.2; --text-h5-regular--letter-spacing: var(--tracking-default); --text-h5-regular--font-weight: var(--font-weight-regular); - --text-h5-medium: var(--text-16); + --text-h5-medium: var(--text-18); --text-h5-medium--line-height: 1.2; --text-h5-medium--letter-spacing: var(--tracking-default); --text-h5-medium--font-weight: var(--font-weight-medium); - --text-h5-semibold: var(--text-16); + --text-h5-semibold: var(--text-18); --text-h5-semibold--line-height: 1.2; --text-h5-semibold--letter-spacing: var(--tracking-default); --text-h5-semibold--font-weight: var(--font-weight-semibold); - --text-h5-bold: var(--text-16); + --text-h5-bold: var(--text-18); --text-h5-bold--line-height: 1.2; --text-h5-bold--letter-spacing: var(--tracking-default); --text-h5-bold--font-weight: var(--font-weight-bold); - --text-h6-regular: var(--text-14); + --text-h6-regular: var(--text-16); --text-h6-regular--line-height: 1.2; --text-h6-regular--letter-spacing: var(--tracking-default); --text-h6-regular--font-weight: var(--font-weight-regular); - --text-h6-medium: var(--text-14); + --text-h6-medium: var(--text-16); --text-h6-medium--line-height: 1.2; --text-h6-medium--letter-spacing: var(--tracking-default); --text-h6-medium--font-weight: var(--font-weight-medium); - --text-h6-semibold: var(--text-14); + --text-h6-semibold: var(--text-16); --text-h6-semibold--line-height: 1.2; --text-h6-semibold--letter-spacing: var(--tracking-default); --text-h6-semibold--font-weight: var(--font-weight-semibold); - --text-h6-bold: var(--text-14); + --text-h6-bold: var(--text-16); --text-h6-bold--line-height: 1.2; --text-h6-bold--letter-spacing: var(--tracking-default); --text-h6-bold--font-weight: var(--font-weight-bold); From 6c8779c8d30dac97f2ff72617b27da9af1e3bc98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:47:30 +0530 Subject: [PATCH 10/63] chore(deps): bump lodash-es in the npm_and_yarn group across 1 directory (#8573) Bumps the npm_and_yarn group with 1 update in the / directory: [lodash-es](https://github.com/lodash/lodash). Updates `lodash-es` from 4.17.21 to 4.17.23 - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23) --- updated-dependencies: - dependency-name: lodash-es dependency-version: 4.17.23 dependency-type: direct:production dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 31 +++++++++++++++++-------------- pnpm-workspace.yaml | 2 +- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcd526473c..f1e628c89a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,8 +61,8 @@ catalogs: specifier: 1.12.0 version: 1.12.0 lodash-es: - specifier: 4.17.21 - version: 4.17.21 + specifier: 4.17.23 + version: 4.17.23 lucide-react: specifier: 0.469.0 version: 0.469.0 @@ -243,7 +243,7 @@ importers: version: 5.1.31 lodash-es: specifier: 'catalog:' - version: 4.17.21 + version: 4.17.23 lucide-react: specifier: 'catalog:' version: 0.469.0(react@18.3.1) @@ -491,7 +491,7 @@ importers: version: 5.1.31 lodash-es: specifier: 'catalog:' - version: 4.17.21 + version: 4.17.23 lucide-react: specifier: 'catalog:' version: 0.469.0(react@18.3.1) @@ -669,7 +669,7 @@ importers: version: 5.1.31 lodash-es: specifier: 'catalog:' - version: 4.17.21 + version: 4.17.23 lucide-react: specifier: 'catalog:' version: 0.469.0(react@18.3.1) @@ -962,7 +962,7 @@ importers: version: 4.3.2 lodash-es: specifier: 'catalog:' - version: 4.17.21 + version: 4.17.23 lowlight: specifier: ^3.0.0 version: 3.3.0 @@ -1060,7 +1060,7 @@ importers: version: 10.7.16 lodash-es: specifier: 'catalog:' - version: 4.17.21 + version: 4.17.23 mobx: specifier: 'catalog:' version: 6.12.0 @@ -1241,7 +1241,7 @@ importers: version: link:../utils lodash-es: specifier: 'catalog:' - version: 4.17.21 + version: 4.17.23 mobx: specifier: 'catalog:' version: 6.12.0 @@ -1356,7 +1356,7 @@ importers: version: 2.1.1 lodash-es: specifier: 'catalog:' - version: 4.17.21 + version: 4.17.23 lucide-react: specifier: 'catalog:' version: 0.469.0(react@18.3.1) @@ -1480,7 +1480,7 @@ importers: version: 10.1.2 lodash-es: specifier: 'catalog:' - version: 4.17.21 + version: 4.17.23 lucide-react: specifier: 'catalog:' version: 0.469.0(react@18.3.1) @@ -1770,6 +1770,7 @@ packages: '@base-ui-components/react@1.0.0-beta.3': resolution: {integrity: sha512-4sAq6zmDA9ixV2HRjjeM1+tSEw5R6nvGjXUQmFoQnC3DZLEUdwO94gWDmUDdpoDuChn27jdbaJs9F0Ih4w2UAA==} engines: {node: '>=14.0.0'} + deprecated: Package was renamed to @base-ui/react peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -1780,6 +1781,7 @@ packages: '@base-ui-components/utils@0.1.1': resolution: {integrity: sha512-HWXZA8upEKgrdL1rQqxWu1H+2tB2cXzY2jCxvgnpUv3eoWN2jldhXxMZnXIjZF7jahGxSWXfSIM/qskiTWFFxA==} + deprecated: Package was renamed to @base-ui/utils peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -6801,8 +6803,8 @@ packages: resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - lodash-es@4.17.21: - resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -9215,6 +9217,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -15425,7 +15428,7 @@ snapshots: dependencies: p-locate: 6.0.0 - lodash-es@4.17.21: {} + lodash-es@4.17.23: {} lodash.debounce@4.0.8: {} @@ -16762,7 +16765,7 @@ snapshots: dependencies: '@icons/material': 0.2.4(react@18.3.1) lodash: 4.17.21 - lodash-es: 4.17.21 + lodash-es: 4.17.23 material-colors: 1.2.6 prop-types: 15.8.1 react: 18.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4d355526d0..c5e2371708 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,7 +24,7 @@ catalog: "@types/react": 18.3.11 axios: 1.12.0 express: 4.22.0 - lodash-es: 4.17.21 + lodash-es: 4.17.23 lucide-react: 0.469.0 mobx-react: 9.1.1 mobx-utils: 6.0.8 From 2a29ab8d4aed1421c97df16954ea3bc50b5069ad Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:23:59 +0530 Subject: [PATCH 11/63] [WEB-5845] chore: changing description field to description json (#8230) * chore: migrating description to description json * chore: replace description with description_json * chore: updated migration file * chore: updated the migration file * chore: added description key in external endpoint * chore: updated the migration file * chore: updated the typo --------- Co-authored-by: Aaryan Khandelwal --- apps/api/plane/api/serializers/intake.py | 5 +++- apps/api/plane/api/serializers/issue.py | 3 +- apps/api/plane/api/views/intake.py | 14 ++++++---- apps/api/plane/app/serializers/issue.py | 2 +- apps/api/plane/app/serializers/page.py | 10 +++---- apps/api/plane/app/views/intake/base.py | 2 +- apps/api/plane/app/views/page/base.py | 2 +- apps/api/plane/bgtasks/copy_s3_object.py | 2 +- .../bgtasks/issue_description_version_sync.py | 4 +-- .../bgtasks/issue_description_version_task.py | 2 +- apps/api/plane/bgtasks/page_version_task.py | 2 +- apps/api/plane/bgtasks/workspace_seed_task.py | 2 +- ...on_draftissue_description_json_and_more.py | 28 +++++++++++++++++++ apps/api/plane/db/models/draft.py | 2 +- apps/api/plane/db/models/issue.py | 4 +-- apps/api/plane/db/models/page.py | 2 +- apps/api/plane/space/serializer/issue.py | 2 +- apps/api/plane/space/views/intake.py | 4 +-- apps/api/plane/space/views/issue.py | 2 +- .../src/controllers/document.controller.ts | 4 +-- apps/live/src/extensions/database.ts | 13 +++++---- apps/live/src/services/page/core.service.ts | 10 ++----- apps/web/core/hooks/use-page-fallback.ts | 2 +- apps/web/core/store/pages/base-page.ts | 8 +++--- packages/editor/src/core/helpers/yjs-utils.ts | 4 +-- packages/types/src/page/core.ts | 4 +-- 26 files changed, 85 insertions(+), 54 deletions(-) create mode 100644 apps/api/plane/db/migrations/0117_rename_description_draftissue_description_json_and_more.py diff --git a/apps/api/plane/api/serializers/intake.py b/apps/api/plane/api/serializers/intake.py index 40cbba38b6..8f560f0757 100644 --- a/apps/api/plane/api/serializers/intake.py +++ b/apps/api/plane/api/serializers/intake.py @@ -13,11 +13,14 @@ class IssueForIntakeSerializer(BaseSerializer): content validation and priority assignment for triage workflows. """ + description = serializers.JSONField(source="description_json", required=False, allow_null=True) + class Meta: model = Issue fields = [ "name", - "description", + "description", # Deprecated + "description_json", "description_html", "priority", ] diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py index d86dfa6b6e..a8bdd557bb 100644 --- a/apps/api/plane/api/serializers/issue.py +++ b/apps/api/plane/api/serializers/issue.py @@ -65,7 +65,7 @@ class IssueSerializer(BaseSerializer): class Meta: model = Issue read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"] - exclude = ["description", "description_stripped"] + exclude = ["description_json", "description_stripped"] def validate(self, data): if ( @@ -633,6 +633,7 @@ class IssueExpandSerializer(BaseSerializer): labels = serializers.SerializerMethodField() assignees = serializers.SerializerMethodField() state = StateLiteSerializer(read_only=True) + description = serializers.JSONField(source="description_json", read_only=True) def get_labels(self, obj): expand = self.context.get("expand", []) diff --git a/apps/api/plane/api/views/intake.py b/apps/api/plane/api/views/intake.py index 216b27afc0..a3d86bf16c 100644 --- a/apps/api/plane/api/views/intake.py +++ b/apps/api/plane/api/views/intake.py @@ -180,11 +180,14 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView): ) # create an issue + issue_data = request.data.get("issue", {}) + # Accept both "description" and "description_json" keys for the description_json field + description_json = issue_data.get("description") or issue_data.get("description_json") or {} issue = Issue.objects.create( - name=request.data.get("issue", {}).get("name"), - description=request.data.get("issue", {}).get("description", {}), - description_html=request.data.get("issue", {}).get("description_html", "

"), - priority=request.data.get("issue", {}).get("priority", "none"), + name=issue_data.get("name"), + description_json=description_json, + description_html=issue_data.get("description_html", "

"), + priority=issue_data.get("priority", "none"), project_id=project_id, state_id=triage_state.id, ) @@ -365,10 +368,11 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView): # Only allow guests to edit name and description if project_member.role <= 5: + description_json = issue_data.get("description") or issue_data.get("description_json") or {} issue_data = { "name": issue_data.get("name", issue.name), "description_html": issue_data.get("description_html", issue.description_html), - "description": issue_data.get("description", issue.description), + "description_json": description_json, } issue_serializer = IssueSerializer(issue, data=issue_data, partial=True) diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index c8f530ba18..b1e1044cd6 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -53,7 +53,7 @@ class IssueFlatSerializer(BaseSerializer): fields = [ "id", "name", - "description", + "description_json", "description_html", "priority", "start_date", diff --git a/apps/api/plane/app/serializers/page.py b/apps/api/plane/app/serializers/page.py index 3aecbafda3..1c2d2d676c 100644 --- a/apps/api/plane/app/serializers/page.py +++ b/apps/api/plane/app/serializers/page.py @@ -58,7 +58,7 @@ class PageSerializer(BaseSerializer): labels = validated_data.pop("labels", None) project_id = self.context["project_id"] owned_by_id = self.context["owned_by_id"] - description = self.context["description"] + description_json = self.context["description_json"] description_binary = self.context["description_binary"] description_html = self.context["description_html"] @@ -68,7 +68,7 @@ class PageSerializer(BaseSerializer): # Create the page page = Page.objects.create( **validated_data, - description=description, + description_json=description_json, description_binary=description_binary, description_html=description_html, owned_by_id=owned_by_id, @@ -171,7 +171,7 @@ class PageBinaryUpdateSerializer(serializers.Serializer): description_binary = serializers.CharField(required=False, allow_blank=True) description_html = serializers.CharField(required=False, allow_blank=True) - description = serializers.JSONField(required=False, allow_null=True) + description_json = serializers.JSONField(required=False, allow_null=True) def validate_description_binary(self, value): """Validate the base64-encoded binary data""" @@ -214,8 +214,8 @@ class PageBinaryUpdateSerializer(serializers.Serializer): if "description_html" in validated_data: instance.description_html = validated_data.get("description_html") - if "description" in validated_data: - instance.description = validated_data.get("description") + if "description_json" in validated_data: + instance.description_json = validated_data.get("description_json") instance.save() return instance diff --git a/apps/api/plane/app/views/intake/base.py b/apps/api/plane/app/views/intake/base.py index 7dd7828cbd..babe2e021e 100644 --- a/apps/api/plane/app/views/intake/base.py +++ b/apps/api/plane/app/views/intake/base.py @@ -394,7 +394,7 @@ class IntakeIssueViewSet(BaseViewSet): issue_data = { "name": issue_data.get("name", issue.name), "description_html": issue_data.get("description_html", issue.description_html), - "description": issue_data.get("description", issue.description), + "description_json": issue_data.get("description_json", issue.description_json), } issue_current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder) diff --git a/apps/api/plane/app/views/page/base.py b/apps/api/plane/app/views/page/base.py index d3ad49b5fd..e94b26b90c 100644 --- a/apps/api/plane/app/views/page/base.py +++ b/apps/api/plane/app/views/page/base.py @@ -128,7 +128,7 @@ class PageViewSet(BaseViewSet): context={ "project_id": project_id, "owned_by_id": request.user.id, - "description": request.data.get("description", {}), + "description_json": request.data.get("description_json", {}), "description_binary": request.data.get("description_binary", None), "description_html": request.data.get("description_html", "

"), }, diff --git a/apps/api/plane/bgtasks/copy_s3_object.py b/apps/api/plane/bgtasks/copy_s3_object.py index e7ef09e353..7db7fd3b3f 100644 --- a/apps/api/plane/bgtasks/copy_s3_object.py +++ b/apps/api/plane/bgtasks/copy_s3_object.py @@ -141,7 +141,7 @@ def copy_s3_objects_of_description_and_assets(entity_name, entity_identifier, pr external_data = sync_with_external_service(entity_name, updated_html) if external_data: - entity.description = external_data.get("description") + entity.description_json = external_data.get("description_json") entity.description_binary = base64.b64decode(external_data.get("description_binary")) entity.save() diff --git a/apps/api/plane/bgtasks/issue_description_version_sync.py b/apps/api/plane/bgtasks/issue_description_version_sync.py index d10ebfcbac..763d10cd54 100644 --- a/apps/api/plane/bgtasks/issue_description_version_sync.py +++ b/apps/api/plane/bgtasks/issue_description_version_sync.py @@ -59,7 +59,7 @@ def sync_issue_description_version(batch_size=5000, offset=0, countdown=300): "description_binary", "description_html", "description_stripped", - "description", + "description_json", )[offset:end_offset] ) @@ -92,7 +92,7 @@ def sync_issue_description_version(batch_size=5000, offset=0, countdown=300): description_binary=issue.description_binary, description_html=issue.description_html, description_stripped=issue.description_stripped, - description_json=issue.description, + description_json=issue.description_json, ) ) diff --git a/apps/api/plane/bgtasks/issue_description_version_task.py b/apps/api/plane/bgtasks/issue_description_version_task.py index 06d15705a7..51d5f4a649 100644 --- a/apps/api/plane/bgtasks/issue_description_version_task.py +++ b/apps/api/plane/bgtasks/issue_description_version_task.py @@ -19,7 +19,7 @@ def should_update_existing_version( def update_existing_version(version: IssueDescriptionVersion, issue) -> None: - version.description_json = issue.description + version.description_json = issue.description_json version.description_html = issue.description_html version.description_binary = issue.description_binary version.description_stripped = issue.description_stripped diff --git a/apps/api/plane/bgtasks/page_version_task.py b/apps/api/plane/bgtasks/page_version_task.py index 4de2387bec..111b4f2360 100644 --- a/apps/api/plane/bgtasks/page_version_task.py +++ b/apps/api/plane/bgtasks/page_version_task.py @@ -28,7 +28,7 @@ def page_version(page_id, existing_instance, user_id): description_binary=page.description_binary, owned_by_id=user_id, last_saved_at=page.updated_at, - description_json=page.description, + description_json=page.description_json, description_stripped=page.description_stripped, ) diff --git a/apps/api/plane/bgtasks/workspace_seed_task.py b/apps/api/plane/bgtasks/workspace_seed_task.py index df69b1f4ac..66c7969b49 100644 --- a/apps/api/plane/bgtasks/workspace_seed_task.py +++ b/apps/api/plane/bgtasks/workspace_seed_task.py @@ -359,7 +359,7 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us is_global=False, access=page_seed.get("access", Page.PUBLIC_ACCESS), name=page_seed.get("name"), - description=page_seed.get("description", {}), + description_json=page_seed.get("description_json", {}), description_html=page_seed.get("description_html", "

"), description_binary=page_seed.get("description_binary", None), description_stripped=page_seed.get("description_stripped", None), diff --git a/apps/api/plane/db/migrations/0117_rename_description_draftissue_description_json_and_more.py b/apps/api/plane/db/migrations/0117_rename_description_draftissue_description_json_and_more.py new file mode 100644 index 0000000000..2317a4cdd7 --- /dev/null +++ b/apps/api/plane/db/migrations/0117_rename_description_draftissue_description_json_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.22 on 2026-01-15 09:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0116_workspacemember_explored_features_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='draftissue', + old_name='description', + new_name='description_json', + ), + migrations.RenameField( + model_name='issue', + old_name='description', + new_name='description_json', + ), + migrations.RenameField( + model_name='page', + old_name='description', + new_name='description_json', + ), + ] diff --git a/apps/api/plane/db/models/draft.py b/apps/api/plane/db/models/draft.py index 55dbb61df9..cabe73d597 100644 --- a/apps/api/plane/db/models/draft.py +++ b/apps/api/plane/db/models/draft.py @@ -39,7 +39,7 @@ class DraftIssue(WorkspaceBaseModel): blank=True, ) name = models.CharField(max_length=255, verbose_name="Issue Name", blank=True, null=True) - description = models.JSONField(blank=True, default=dict) + description_json = models.JSONField(blank=True, default=dict) description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) description_binary = models.BinaryField(null=True) diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index 68a4ae6dd0..a2b3af41b6 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -128,7 +128,7 @@ class Issue(ProjectBaseModel): blank=True, ) name = models.CharField(max_length=255, verbose_name="Issue Name") - description = models.JSONField(blank=True, default=dict) + description_json = models.JSONField(blank=True, default=dict) description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) description_binary = models.BinaryField(null=True) @@ -800,7 +800,7 @@ class IssueDescriptionVersion(ProjectBaseModel): description_binary=issue.description_binary, description_html=issue.description_html, description_stripped=issue.description_stripped, - description_json=issue.description, + description_json=issue.description_json, ) return True except Exception as e: diff --git a/apps/api/plane/db/models/page.py b/apps/api/plane/db/models/page.py index 213954d149..e51ee9b4c8 100644 --- a/apps/api/plane/db/models/page.py +++ b/apps/api/plane/db/models/page.py @@ -25,7 +25,7 @@ class Page(BaseModel): workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="pages") name = models.TextField(blank=True) - description = models.JSONField(default=dict, blank=True) + description_json = models.JSONField(default=dict, blank=True) description_binary = models.BinaryField(null=True) description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) diff --git a/apps/api/plane/space/serializer/issue.py b/apps/api/plane/space/serializer/issue.py index a89846cfc7..237e00c5dd 100644 --- a/apps/api/plane/space/serializer/issue.py +++ b/apps/api/plane/space/serializer/issue.py @@ -193,7 +193,7 @@ class IssueFlatSerializer(BaseSerializer): fields = [ "id", "name", - "description", + "description_json", "description_html", "priority", "start_date", diff --git a/apps/api/plane/space/views/intake.py b/apps/api/plane/space/views/intake.py index 7ea2dee91f..d4f6f6b7e4 100644 --- a/apps/api/plane/space/views/intake.py +++ b/apps/api/plane/space/views/intake.py @@ -140,7 +140,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): # create an issue issue = Issue.objects.create( name=request.data.get("issue", {}).get("name"), - description=request.data.get("issue", {}).get("description", {}), + description_json=request.data.get("issue", {}).get("description_json", {}), description_html=request.data.get("issue", {}).get("description_html", "

"), priority=request.data.get("issue", {}).get("priority", "low"), project_id=project_deploy_board.project_id, @@ -201,7 +201,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): issue_data = { "name": issue_data.get("name", issue.name), "description_html": issue_data.get("description_html", issue.description_html), - "description": issue_data.get("description", issue.description), + "description_json": issue_data.get("description_json", issue.description_json), } issue_serializer = IssueCreateSerializer( diff --git a/apps/api/plane/space/views/issue.py b/apps/api/plane/space/views/issue.py index 220fc13073..d0d926319c 100644 --- a/apps/api/plane/space/views/issue.py +++ b/apps/api/plane/space/views/issue.py @@ -744,7 +744,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView): "name", "state_id", "sort_order", - "description", + "description_json", "description_html", "description_stripped", "description_binary", diff --git a/apps/live/src/controllers/document.controller.ts b/apps/live/src/controllers/document.controller.ts index 832045766f..b77426ab5d 100644 --- a/apps/live/src/controllers/document.controller.ts +++ b/apps/live/src/controllers/document.controller.ts @@ -27,14 +27,14 @@ export class DocumentController { const { description_html, variant } = validatedData; // Process document conversion - const { description, description_binary } = convertHTMLDocumentToAllFormats({ + const { description_json, description_binary } = convertHTMLDocumentToAllFormats({ document_html: description_html, variant, }); // Return successful response res.status(200).json({ - description, + description_json, description_binary, }); } catch (error) { diff --git a/apps/live/src/extensions/database.ts b/apps/live/src/extensions/database.ts index 2c6c6ac7e5..1b40d3b231 100644 --- a/apps/live/src/extensions/database.ts +++ b/apps/live/src/extensions/database.ts @@ -1,11 +1,12 @@ import { Database as HocuspocusDatabase } from "@hocuspocus/extension-database"; -// utils +// plane imports import { getAllDocumentFormatsFromDocumentEditorBinaryData, getBinaryDataFromDocumentEditorHTMLString, } from "@plane/editor"; -// logger +import type { TDocumentPayload } from "@plane/types"; import { logger } from "@plane/logger"; +// lib import { AppError } from "@/lib/errors"; // services import { getPageService } from "@/services/page/handler"; @@ -36,10 +37,10 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP convertedBinaryData, true ); - const payload = { + const payload: TDocumentPayload = { description_binary: contentBinaryEncoded, description_html: contentHTML, - description: contentJSON, + description_json: contentJSON, }; await service.updateDescriptionBinary(pageId, payload); } catch (e) { @@ -76,10 +77,10 @@ const storeDocument = async ({ true ); // create payload - const payload = { + const payload: TDocumentPayload = { description_binary: contentBinaryEncoded, description_html: contentHTML, - description: contentJSON, + description_json: contentJSON, }; await service.updateDescriptionBinary(pageId, payload); } catch (error) { diff --git a/apps/live/src/services/page/core.service.ts b/apps/live/src/services/page/core.service.ts index 04a0640912..53ba24261c 100644 --- a/apps/live/src/services/page/core.service.ts +++ b/apps/live/src/services/page/core.service.ts @@ -1,15 +1,9 @@ import { logger } from "@plane/logger"; -import type { TPage } from "@plane/types"; +import type { TDocumentPayload, TPage } from "@plane/types"; // services import { AppError } from "@/lib/errors"; import { APIService } from "../api.service"; -export type TPageDescriptionPayload = { - description_binary: string; - description_html: string; - description: object; -}; - export abstract class PageCoreService extends APIService { protected abstract basePath: string; @@ -103,7 +97,7 @@ export abstract class PageCoreService extends APIService { } } - async updateDescriptionBinary(pageId: string, data: TPageDescriptionPayload): Promise { + async updateDescriptionBinary(pageId: string, data: TDocumentPayload): Promise { return this.patch(`${this.basePath}/pages/${pageId}/description/`, data, { headers: this.getHeader(), }) diff --git a/apps/web/core/hooks/use-page-fallback.ts b/apps/web/core/hooks/use-page-fallback.ts index 2b88b0d5af..1392bb639a 100644 --- a/apps/web/core/hooks/use-page-fallback.ts +++ b/apps/web/core/hooks/use-page-fallback.ts @@ -59,7 +59,7 @@ export const usePageFallback = (args: TArgs) => { await updatePageDescription({ description_binary: encodedBinary, description_html: html, - description: json, + description_json: json, }); } catch (error: any) { console.error(error); diff --git a/apps/web/core/store/pages/base-page.ts b/apps/web/core/store/pages/base-page.ts index bf8d53671d..8410781ade 100644 --- a/apps/web/core/store/pages/base-page.ts +++ b/apps/web/core/store/pages/base-page.ts @@ -80,7 +80,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage { id: string | undefined; name: string | undefined; logo_props: TLogoProps | undefined; - description: object | undefined; + description_json: object | undefined; description_html: string | undefined; color: string | undefined; label_ids: string[] | undefined; @@ -117,7 +117,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage { this.id = page?.id || undefined; this.name = page?.name; this.logo_props = page?.logo_props || undefined; - this.description = page?.description || undefined; + this.description_json = page?.description_json || undefined; this.description_html = page?.description_html || undefined; this.color = page?.color || undefined; this.label_ids = page?.label_ids || undefined; @@ -142,7 +142,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage { id: observable.ref, name: observable.ref, logo_props: observable.ref, - description: observable, + description_json: observable.ref, description_html: observable.ref, color: observable.ref, label_ids: observable, @@ -217,7 +217,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage { return { id: this.id, name: this.name, - description: this.description, + description_json: this.description_json, description_html: this.description_html, color: this.color, label_ids: this.label_ids, diff --git a/packages/editor/src/core/helpers/yjs-utils.ts b/packages/editor/src/core/helpers/yjs-utils.ts index bb7d1fef68..61c6923c74 100644 --- a/packages/editor/src/core/helpers/yjs-utils.ts +++ b/packages/editor/src/core/helpers/yjs-utils.ts @@ -215,7 +215,7 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary); allFormats = { - description: contentJSON, + description_json: contentJSON, description_html: contentHTML, description_binary: contentBinaryEncoded, }; @@ -228,7 +228,7 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF false ); allFormats = { - description: contentJSON, + description_json: contentJSON, description_html: contentHTML, description_binary: contentBinaryEncoded, }; diff --git a/packages/types/src/page/core.ts b/packages/types/src/page/core.ts index 99b4641885..d1703ad062 100644 --- a/packages/types/src/page/core.ts +++ b/packages/types/src/page/core.ts @@ -8,7 +8,7 @@ export type TPage = { color: string | undefined; created_at: Date | undefined; created_by: string | undefined; - description: object | undefined; + description_json: object | undefined; description_html: string | undefined; id: string | undefined; is_favorite: boolean; @@ -66,7 +66,7 @@ export type TPageVersion = { export type TDocumentPayload = { description_binary: string; description_html: string; - description: object; + description_json: object; }; export type TWebhookConnectionQueryParams = { From bb4f172e26243c3d6ba83359f4321c3ffd11f5b4 Mon Sep 17 00:00:00 2001 From: yy Date: Fri, 23 Jan 2026 17:02:04 +0900 Subject: [PATCH 12/63] chore: fix typos in comments (#8553) --- apps/space/core/components/issues/issue-layouts/utils.tsx | 2 +- apps/web/core/components/issues/issue-layouts/utils.tsx | 2 +- apps/web/core/store/issue/helpers/base-issues.store.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/space/core/components/issues/issue-layouts/utils.tsx b/apps/space/core/components/issues/issue-layouts/utils.tsx index e8e2a6c699..33d811bd51 100644 --- a/apps/space/core/components/issues/issue-layouts/utils.tsx +++ b/apps/space/core/components/issues/issue-layouts/utils.tsx @@ -218,7 +218,7 @@ export const removeNillKeys = (obj: T) => Object.fromEntries(Object.entries(obj ?? {}).filter(([key, value]) => key && !isNil(value))); /** - * This Method returns if the the grouped values are subGrouped + * This Method returns if the grouped values are subGrouped * @param groupedIssueIds * @returns */ diff --git a/apps/web/core/components/issues/issue-layouts/utils.tsx b/apps/web/core/components/issues/issue-layouts/utils.tsx index d3cca81dd7..1417343b8a 100644 --- a/apps/web/core/components/issues/issue-layouts/utils.tsx +++ b/apps/web/core/components/issues/issue-layouts/utils.tsx @@ -589,7 +589,7 @@ export const removeNillKeys = (obj: T) => Object.fromEntries(Object.entries(obj ?? {}).filter(([key, value]) => key && !isNil(value))); /** - * This Method returns if the the grouped values are subGrouped + * This Method returns if the grouped values are subGrouped * @param groupedIssueIds * @returns */ diff --git a/apps/web/core/store/issue/helpers/base-issues.store.ts b/apps/web/core/store/issue/helpers/base-issues.store.ts index 2948834886..f336c225cb 100644 --- a/apps/web/core/store/issue/helpers/base-issues.store.ts +++ b/apps/web/core/store/issue/helpers/base-issues.store.ts @@ -1469,7 +1469,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { // if the key for accumulator is not the current action, // Meaning if the key already has an action ADD and the current one is REMOVE, - // The the key is deleted as both the actions cancel each other out + // The key is deleted as both the actions cancel each other out if (accumulator[key] !== action) { delete accumulator[key]; } From ba5ba5bf54d4f8195392e7305a6a5cc6ede980ed Mon Sep 17 00:00:00 2001 From: Sangeetha Date: Fri, 23 Jan 2026 13:33:20 +0530 Subject: [PATCH 13/63] [GIT-61] chore: allow .md files to be uploaded (#8571) * chore: allow .md files to be uploaded * chore: allow .md files to be uploaded --- apps/api/plane/settings/common.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 8f28b9e986..8b94ee44e3 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -447,6 +447,8 @@ ATTACHMENT_MIME_TYPES = [ "application/x-sql", # Gzip "application/x-gzip", + # Markdown + "text/markdown", ] # Seed directory path From db8b67102df81b990783697f132f968ce90f321d Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:34:20 +0530 Subject: [PATCH 14/63] [WEB-5860] [WEB-5861] [WEB-5862] style: improved settings interface (#8520) * style: improved profile settings * chore: minor improvements * style: improved workspace settings * style: workspace settings content * style: improved project settings * fix: project settings flat map * chore: add back navigation from settings pages * style: settings content * style: estimates list * refactor: remove old code * refactor: removed unnecessary line breaks * refactor: create a common component for page header * chore: add fade-in animation to sidebar * fix: formatting * fix: project settings sidebar header * fix: workspace settings sidebar header * fix: settings content wrapper scroll * chore: separate project settings features * fix: formatting * refactor: custom theme selector * refactor: settings headings * refactor: settings headings * fix: project settings sidebar padding * fix: sidebar header padding * fix: sidebar item permissions * fix: missing editable check * refactor: remove unused files * chore: remove unnecessary code * chore: add missing translations * fix: formatting --- .../[workspaceSlug]/(settings)/layout.tsx | 5 +- .../settings/(workspace)/billing/header.tsx | 36 +++ .../settings/(workspace)/billing/page.tsx | 6 +- .../settings/(workspace)/exports/header.tsx | 36 +++ .../settings/(workspace)/exports/page.tsx | 15 +- .../settings/(workspace)/header.tsx | 36 +++ .../settings/(workspace)/imports/page.tsx | 35 --- .../(workspace)/integrations/page.tsx | 7 +- .../settings/(workspace)/layout.tsx | 16 +- .../settings/(workspace)/members/header.tsx | 36 +++ .../settings/(workspace)/members/page.tsx | 10 +- .../(workspace)/mobile-header-tabs.tsx | 40 --- .../(settings)/settings/(workspace)/page.tsx | 8 +- .../settings/(workspace)/sidebar.tsx | 72 ----- .../webhooks/[webhookId]/header.tsx | 36 +++ .../(workspace)/webhooks/[webhookId]/page.tsx | 4 +- .../settings/(workspace)/webhooks/header.tsx | 36 +++ .../settings/(workspace)/webhooks/page.tsx | 20 +- .../settings/account/api-tokens/page.tsx | 98 ------ .../(settings)/settings/account/layout.tsx | 32 -- .../settings/account/preferences/page.tsx | 39 --- .../settings/account/security/page.tsx | 262 ---------------- .../(settings)/settings/account/sidebar.tsx | 76 ----- .../[projectId]/automations/header.tsx | 36 +++ .../projects/[projectId]/automations/page.tsx | 15 +- .../projects/[projectId]/estimates/header.tsx | 36 +++ .../projects/[projectId]/estimates/page.tsx | 6 +- .../[projectId]/features/cycles/header.tsx | 36 +++ .../[projectId]/features/cycles/page.tsx | 58 ++++ .../[projectId]/features/intake/header.tsx | 36 +++ .../[projectId]/features/intake/page.tsx | 58 ++++ .../[projectId]/features/modules/header.tsx | 36 +++ .../[projectId]/features/modules/page.tsx | 58 ++++ .../projects/[projectId]/features/page.tsx | 41 --- .../[projectId]/features/pages/header.tsx | 36 +++ .../[projectId]/features/pages/page.tsx | 58 ++++ .../[projectId]/features/views/header.tsx | 36 +++ .../[projectId]/features/views/page.tsx | 58 ++++ .../settings/projects/[projectId]/header.tsx | 36 +++ .../projects/[projectId]/labels/header.tsx | 36 +++ .../projects/[projectId]/labels/page.tsx | 8 +- .../settings/projects/[projectId]/layout.tsx | 23 +- .../projects/[projectId]/members/header.tsx | 36 +++ .../projects/[projectId]/members/page.tsx | 6 +- .../settings/projects/[projectId]/page.tsx | 44 +-- .../projects/[projectId]/states/header.tsx | 36 +++ .../projects/[projectId]/states/page.tsx | 10 +- apps/web/app/(all)/[workspaceSlug]/layout.tsx | 7 +- apps/web/app/(all)/profile/activity/page.tsx | 83 ------ .../web/app/(all)/profile/appearance/page.tsx | 101 ------- .../app/(all)/profile/notifications/page.tsx | 37 --- apps/web/app/(all)/profile/page.tsx | 34 --- apps/web/app/(all)/profile/sidebar.tsx | 279 ------------------ .../settings/profile/[profileTabId]/page.tsx | 55 ++++ .../(all)/{ => settings}/profile/layout.tsx | 13 +- apps/web/app/routes/core.ts | 64 ++-- .../app/routes/redirects/core/api-tokens.tsx | 6 +- apps/web/app/routes/redirects/core/index.ts | 2 +- .../redirects/core/profile-settings.tsx | 12 + .../core/workspace-account-settings.tsx | 12 + .../web/ce/components/common/modal/global.tsx | 26 ++ .../navigations/top-navigation-root.tsx | 2 +- apps/web/ce/components/preferences/config.ts | 7 - .../components/preferences/theme-switcher.tsx | 19 +- .../ce/components/workspace/billing/root.tsx | 43 ++- .../workspace/delete-workspace-section.tsx | 56 ++-- .../ce/constants/project/settings/index.ts | 1 - .../web/ce/constants/project/settings/tabs.ts | 82 ----- apps/web/core/components/appearance/index.ts | 1 + .../components/appearance/theme-switcher.tsx | 70 +++++ .../automation/auto-archive-automation.tsx | 37 +-- .../automation/auto-close-automation.tsx | 63 ++-- .../components/core/theme/color-inputs.tsx | 92 ++++++ .../core/theme/custom-theme-selector.tsx | 156 +++------- .../core/theme/download-config-button.tsx | 59 ++++ ...g-handler.tsx => import-config-button.tsx} | 52 +--- .../core/theme/theme-mode-selector.tsx | 51 ++++ .../components/core/theme/theme-switch.tsx | 1 + .../estimates/estimate-list-item.tsx | 50 ++-- apps/web/core/components/estimates/root.tsx | 107 +++---- .../core/components/exporter/export-form.tsx | 130 ++++---- apps/web/core/components/exporter/guide.tsx | 22 +- .../core/components/exporter/prev-exports.tsx | 7 +- .../components/global/timezone-select.tsx | 7 +- .../home/widgets/empty-states/no-projects.tsx | 2 +- .../integration/delete-import-modal.tsx | 110 ------- .../components/integration/github/auth.tsx | 38 --- .../integration/github/import-configure.tsx | 50 ---- .../integration/github/import-confirm.tsx | 32 -- .../integration/github/import-data.tsx | 121 -------- .../integration/github/import-users.tsx | 51 ---- .../components/integration/github/index.ts | 9 - .../integration/github/repo-details.tsx | 104 ------- .../components/integration/github/root.tsx | 246 --------------- .../integration/github/single-user-select.tsx | 132 --------- .../web/core/components/integration/guide.tsx | 177 ----------- apps/web/core/components/integration/index.ts | 12 - .../integration/jira/confirm-import.tsx | 47 --- .../integration/jira/give-details.tsx | 214 -------------- .../integration/jira/import-users.tsx | 152 ---------- .../core/components/integration/jira/index.ts | 39 --- .../integration/jira/jira-project-detail.tsx | 167 ----------- .../core/components/integration/jira/root.tsx | 200 ------------- .../components/integration/single-import.tsx | 65 ---- .../components/integration/slack/index.ts | 1 - .../labels/project-setting-label-list.tsx | 84 +++--- .../components/navigation/app-rail-root.tsx | 8 +- .../open-entity/project-settings-menu.tsx | 10 +- .../open-entity/workspace-settings-menu.tsx | 5 +- apps/web/core/components/preferences/list.tsx | 13 - .../core/components/preferences/section.tsx | 17 -- .../notification/email-notification-form.tsx | 168 ----------- .../profile/preferences/language-timezone.tsx | 99 ------- .../profile-setting-content-header.tsx | 14 - apps/web/core/components/profile/sidebar.tsx | 32 +- .../profile/start-of-week-preference.tsx | 39 +-- .../core/components/project-states/root.tsx | 14 +- .../archive-restore-modal.tsx | 0 apps/web/core/components/project/card.tsx | 2 +- apps/web/core/components/project/form.tsx | 2 +- .../components/project/integration-card.tsx | 4 +- .../settings/archive-project/selection.tsx | 61 ---- .../project/settings/control-section.tsx | 82 +++++ .../settings/delete-project-section.tsx | 68 ----- .../project/settings/features-list.tsx | 66 ++--- .../settings/boxed-control-item.tsx | 28 ++ .../components/settings/content-wrapper.tsx | 43 ++- .../core/components/settings/control-item.tsx | 19 ++ apps/web/core/components/settings/header.tsx | 81 ----- apps/web/core/components/settings/heading.tsx | 56 ++-- apps/web/core/components/settings/helper.ts | 15 +- .../core/components/settings/mobile/index.ts | 1 - .../core/components/settings/mobile/nav.tsx | 33 +-- .../core/components/settings/page-header.tsx | 17 ++ .../{sidebar => profile/content}/index.ts | 0 .../content/pages/activity/activity-list.tsx | 188 ++++++++++++ .../profile/content/pages/activity/index.ts | 1 + .../profile/content/pages/activity/root.tsx} | 32 +- .../profile/content/pages/api-tokens.tsx | 73 +++++ .../profile/content/pages/general}/form.tsx | 60 ++-- .../profile/content/pages/general/index.ts | 1 + .../profile/content/pages/general/root.tsx} | 14 +- .../settings/profile/content/pages/index.ts | 12 + .../notifications/email-notification-form.tsx | 161 ++++++++++ .../content/pages/notifications/index.ts | 1 + .../content/pages/notifications/root.tsx} | 23 +- .../pages/preferences/default-list.tsx | 17 ++ .../content/pages/preferences/index.ts | 1 + .../language-and-timezone-list.tsx | 102 +++++++ .../content/pages/preferences/root.tsx | 36 +++ .../profile/content/pages/security.tsx} | 117 ++++---- .../settings/profile/content/root.tsx | 32 ++ .../components/settings/profile/heading.tsx | 21 ++ .../components/settings/profile/modal.tsx | 53 ++++ .../settings/profile/sidebar/header.tsx | 31 ++ .../settings/profile/sidebar/index.ts | 1 + .../profile/sidebar/item-categories.tsx | 66 +++++ .../settings/profile/sidebar/root.tsx | 29 ++ .../profile/sidebar/workspace-options.tsx | 50 ++++ .../project/content/feature-control-item.tsx | 60 ++++ .../settings/project/sidebar/header.tsx | 58 ++++ .../project/sidebar/item-categories.tsx | 67 +++++ .../settings/project/sidebar/item-icon.tsx | 31 ++ .../project/sidebar/nav-item-children.tsx | 79 ----- .../settings/project/sidebar/root.tsx | 62 ++-- .../components/settings/sidebar/header.tsx | 40 --- .../core/components/settings/sidebar/item.tsx | 54 ++++ .../components/settings/sidebar/nav-item.tsx | 91 ------ .../core/components/settings/sidebar/root.tsx | 77 ----- apps/web/core/components/settings/tabs.tsx | 63 ---- .../settings/workspace/sidebar/header.tsx | 60 ++++ .../settings/workspace/sidebar/index.ts | 1 + .../workspace/sidebar/item-categories.tsx | 61 ++++ .../settings/workspace/sidebar/item-icon.tsx | 13 + .../settings/workspace/sidebar/root.tsx | 29 ++ .../components/sidebar/sidebar-wrapper.tsx | 2 +- .../web-hooks/webhooks-list-item.tsx | 13 +- .../components/web-hooks/webhooks-list.tsx | 2 +- .../workspace/billing/comparison/base.tsx | 2 +- .../workspace/settings/members-list-item.tsx | 2 +- .../workspace/settings/members-list.tsx | 2 +- .../workspace/settings/workspace-details.tsx | 82 +++-- .../workspace/sidebar/user-menu-root.tsx | 126 ++++---- .../layouts/auth-layout/workspace-wrapper.tsx | 7 +- .../core/store/base-command-palette.store.ts | 32 +- .../web/ee/constants/project/settings/tabs.ts | 1 - packages/constants/src/profile.ts | 58 +--- packages/constants/src/settings.ts | 52 ---- packages/constants/src/settings/index.ts | 3 + packages/constants/src/settings/profile.ts | 61 ++++ packages/constants/src/settings/project.ts | 116 ++++++++ packages/constants/src/settings/workspace.ts | 68 +++++ packages/constants/src/workspace.ts | 60 +--- packages/i18n/src/locales/cs/translations.ts | 38 +++ packages/i18n/src/locales/de/translations.ts | 40 +++ packages/i18n/src/locales/en/translations.ts | 37 +++ packages/i18n/src/locales/es/translations.ts | 40 +++ packages/i18n/src/locales/fr/translations.ts | 40 +++ packages/i18n/src/locales/id/translations.ts | 38 +++ packages/i18n/src/locales/it/translations.ts | 40 +++ packages/i18n/src/locales/ja/translations.ts | 37 +++ packages/i18n/src/locales/ko/translations.ts | 37 +++ packages/i18n/src/locales/pl/translations.ts | 39 +++ .../i18n/src/locales/pt-BR/translations.ts | 38 +++ packages/i18n/src/locales/ro/translations.ts | 39 +++ packages/i18n/src/locales/ru/translations.ts | 40 +++ packages/i18n/src/locales/sk/translations.ts | 38 +++ .../i18n/src/locales/tr-TR/translations.ts | 38 +++ packages/i18n/src/locales/ua/translations.ts | 39 +++ .../i18n/src/locales/vi-VN/translations.ts | 39 +++ .../i18n/src/locales/zh-CN/translations.ts | 37 +++ .../i18n/src/locales/zh-TW/translations.ts | 37 +++ packages/tailwind-config/animations.css | 11 + packages/types/src/index.ts | 1 + packages/types/src/settings.ts | 34 +++ packages/ui/src/tables/table.tsx | 8 +- 216 files changed, 4684 insertions(+), 5454 deletions(-) create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/header.tsx delete mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/header.tsx delete mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx delete mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/header.tsx delete mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx delete mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx delete mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx delete mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx delete mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/page.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/page.tsx delete mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/page.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/page.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/header.tsx create mode 100644 apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/header.tsx delete mode 100644 apps/web/app/(all)/profile/activity/page.tsx delete mode 100644 apps/web/app/(all)/profile/appearance/page.tsx delete mode 100644 apps/web/app/(all)/profile/notifications/page.tsx delete mode 100644 apps/web/app/(all)/profile/page.tsx delete mode 100644 apps/web/app/(all)/profile/sidebar.tsx create mode 100644 apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx rename apps/web/app/(all)/{ => settings}/profile/layout.tsx (56%) create mode 100644 apps/web/app/routes/redirects/core/profile-settings.tsx create mode 100644 apps/web/app/routes/redirects/core/workspace-account-settings.tsx create mode 100644 apps/web/ce/components/common/modal/global.tsx delete mode 100644 apps/web/ce/components/preferences/config.ts delete mode 100644 apps/web/ce/constants/project/settings/tabs.ts create mode 100644 apps/web/core/components/appearance/index.ts create mode 100644 apps/web/core/components/appearance/theme-switcher.tsx create mode 100644 apps/web/core/components/core/theme/color-inputs.tsx create mode 100644 apps/web/core/components/core/theme/download-config-button.tsx rename apps/web/core/components/core/theme/{config-handler.tsx => import-config-button.tsx} (62%) create mode 100644 apps/web/core/components/core/theme/theme-mode-selector.tsx delete mode 100644 apps/web/core/components/integration/delete-import-modal.tsx delete mode 100644 apps/web/core/components/integration/github/auth.tsx delete mode 100644 apps/web/core/components/integration/github/import-configure.tsx delete mode 100644 apps/web/core/components/integration/github/import-confirm.tsx delete mode 100644 apps/web/core/components/integration/github/import-data.tsx delete mode 100644 apps/web/core/components/integration/github/import-users.tsx delete mode 100644 apps/web/core/components/integration/github/index.ts delete mode 100644 apps/web/core/components/integration/github/repo-details.tsx delete mode 100644 apps/web/core/components/integration/github/root.tsx delete mode 100644 apps/web/core/components/integration/github/single-user-select.tsx delete mode 100644 apps/web/core/components/integration/guide.tsx delete mode 100644 apps/web/core/components/integration/index.ts delete mode 100644 apps/web/core/components/integration/jira/confirm-import.tsx delete mode 100644 apps/web/core/components/integration/jira/give-details.tsx delete mode 100644 apps/web/core/components/integration/jira/import-users.tsx delete mode 100644 apps/web/core/components/integration/jira/index.ts delete mode 100644 apps/web/core/components/integration/jira/jira-project-detail.tsx delete mode 100644 apps/web/core/components/integration/jira/root.tsx delete mode 100644 apps/web/core/components/integration/single-import.tsx delete mode 100644 apps/web/core/components/integration/slack/index.ts delete mode 100644 apps/web/core/components/preferences/list.tsx delete mode 100644 apps/web/core/components/preferences/section.tsx delete mode 100644 apps/web/core/components/profile/notification/email-notification-form.tsx delete mode 100644 apps/web/core/components/profile/preferences/language-timezone.tsx delete mode 100644 apps/web/core/components/profile/profile-setting-content-header.tsx rename apps/web/core/components/project/{settings/archive-project => }/archive-restore-modal.tsx (100%) delete mode 100644 apps/web/core/components/project/settings/archive-project/selection.tsx create mode 100644 apps/web/core/components/project/settings/control-section.tsx delete mode 100644 apps/web/core/components/project/settings/delete-project-section.tsx create mode 100644 apps/web/core/components/settings/boxed-control-item.tsx create mode 100644 apps/web/core/components/settings/control-item.tsx delete mode 100644 apps/web/core/components/settings/header.tsx delete mode 100644 apps/web/core/components/settings/mobile/index.ts create mode 100644 apps/web/core/components/settings/page-header.tsx rename apps/web/core/components/settings/{sidebar => profile/content}/index.ts (100%) create mode 100644 apps/web/core/components/settings/profile/content/pages/activity/activity-list.tsx create mode 100644 apps/web/core/components/settings/profile/content/pages/activity/index.ts rename apps/web/{app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx => core/components/settings/profile/content/pages/activity/root.tsx} (81%) create mode 100644 apps/web/core/components/settings/profile/content/pages/api-tokens.tsx rename apps/web/core/components/{profile => settings/profile/content/pages/general}/form.tsx (88%) create mode 100644 apps/web/core/components/settings/profile/content/pages/general/index.ts rename apps/web/{app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx => core/components/settings/profile/content/pages/general/root.tsx} (61%) create mode 100644 apps/web/core/components/settings/profile/content/pages/index.ts create mode 100644 apps/web/core/components/settings/profile/content/pages/notifications/email-notification-form.tsx create mode 100644 apps/web/core/components/settings/profile/content/pages/notifications/index.ts rename apps/web/{app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx => core/components/settings/profile/content/pages/notifications/root.tsx} (60%) create mode 100644 apps/web/core/components/settings/profile/content/pages/preferences/default-list.tsx create mode 100644 apps/web/core/components/settings/profile/content/pages/preferences/index.ts create mode 100644 apps/web/core/components/settings/profile/content/pages/preferences/language-and-timezone-list.tsx create mode 100644 apps/web/core/components/settings/profile/content/pages/preferences/root.tsx rename apps/web/{app/(all)/profile/security/page.tsx => core/components/settings/profile/content/pages/security.tsx} (73%) create mode 100644 apps/web/core/components/settings/profile/content/root.tsx create mode 100644 apps/web/core/components/settings/profile/heading.tsx create mode 100644 apps/web/core/components/settings/profile/modal.tsx create mode 100644 apps/web/core/components/settings/profile/sidebar/header.tsx create mode 100644 apps/web/core/components/settings/profile/sidebar/index.ts create mode 100644 apps/web/core/components/settings/profile/sidebar/item-categories.tsx create mode 100644 apps/web/core/components/settings/profile/sidebar/root.tsx create mode 100644 apps/web/core/components/settings/profile/sidebar/workspace-options.tsx create mode 100644 apps/web/core/components/settings/project/content/feature-control-item.tsx create mode 100644 apps/web/core/components/settings/project/sidebar/header.tsx create mode 100644 apps/web/core/components/settings/project/sidebar/item-categories.tsx create mode 100644 apps/web/core/components/settings/project/sidebar/item-icon.tsx delete mode 100644 apps/web/core/components/settings/project/sidebar/nav-item-children.tsx delete mode 100644 apps/web/core/components/settings/sidebar/header.tsx create mode 100644 apps/web/core/components/settings/sidebar/item.tsx delete mode 100644 apps/web/core/components/settings/sidebar/nav-item.tsx delete mode 100644 apps/web/core/components/settings/sidebar/root.tsx delete mode 100644 apps/web/core/components/settings/tabs.tsx create mode 100644 apps/web/core/components/settings/workspace/sidebar/header.tsx create mode 100644 apps/web/core/components/settings/workspace/sidebar/index.ts create mode 100644 apps/web/core/components/settings/workspace/sidebar/item-categories.tsx create mode 100644 apps/web/core/components/settings/workspace/sidebar/item-icon.tsx create mode 100644 apps/web/core/components/settings/workspace/sidebar/root.tsx delete mode 100644 apps/web/ee/constants/project/settings/tabs.ts delete mode 100644 packages/constants/src/settings.ts create mode 100644 packages/constants/src/settings/index.ts create mode 100644 packages/constants/src/settings/profile.ts create mode 100644 packages/constants/src/settings/project.ts create mode 100644 packages/constants/src/settings/workspace.ts create mode 100644 packages/types/src/settings.ts diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx index 982489d500..142f94ecf5 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx @@ -2,7 +2,6 @@ import { Outlet } from "react-router"; // components import { ContentWrapper } from "@/components/core/content-wrapper"; import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider"; -import { SettingsHeader } from "@/components/settings/header"; export default function SettingsLayout() { return ( @@ -10,10 +9,8 @@ export default function SettingsLayout() {
- {/* Header */} - {/* Content */} - +
diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/header.tsx new file mode 100644 index 0000000000..71e84bf53c --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { WORKSPACE_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; + +export const BillingWorkspaceSettingsHeader = observer(function BillingWorkspaceSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = WORKSPACE_SETTINGS["billing-and-plans"]; + const Icon = WORKSPACE_SETTINGS_ICONS["billing-and-plans"]; + + return ( + + + } + /> + } + /> + +
+ } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx index 0ea5d2c9c9..d15d68b1db 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx @@ -3,12 +3,14 @@ import { observer } from "mobx-react"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { PageHead } from "@/components/core/page-title"; -// hooks import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +// hooks import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUserPermissions } from "@/hooks/store/user"; // plane web components import { BillingRoot } from "@/plane-web/components/workspace/billing"; +// local imports +import { BillingWorkspaceSettingsHeader } from "./header"; function BillingSettingsPage() { // store hooks @@ -23,7 +25,7 @@ function BillingSettingsPage() { } return ( - + } hugging> diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/header.tsx new file mode 100644 index 0000000000..668f45fa25 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { WORKSPACE_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; + +export const ExportsWorkspaceSettingsHeader = observer(function ExportsWorkspaceSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = WORKSPACE_SETTINGS.export; + const Icon = WORKSPACE_SETTINGS_ICONS.export; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx index 5891e50845..dbe9e2b394 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx @@ -1,17 +1,18 @@ import { observer } from "mobx-react"; -// components import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { cn } from "@plane/utils"; +// components import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { PageHead } from "@/components/core/page-title"; -import ExportGuide from "@/components/exporter/guide"; -// helpers -// hooks +import { ExportGuide } from "@/components/exporter/guide"; import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; -import SettingsHeading from "@/components/settings/heading"; +import { SettingsHeading } from "@/components/settings/heading"; +// hooks import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import { ExportsWorkspaceSettingsHeader } from "./header"; function ExportsPage() { // store hooks @@ -34,10 +35,10 @@ function ExportsPage() { } return ( - + } hugging>
diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/header.tsx new file mode 100644 index 0000000000..4351be5346 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { WORKSPACE_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; + +export const GeneralWorkspaceSettingsHeader = observer(function GeneralWorkspaceSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = WORKSPACE_SETTINGS.general; + const Icon = WORKSPACE_SETTINGS_ICONS.general; + + return ( + + + } + /> + } + /> + +
+ } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx deleted file mode 100644 index 89eb978fce..0000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { observer } from "mobx-react"; -// components -import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; -import { PageHead } from "@/components/core/page-title"; -import IntegrationGuide from "@/components/integration/guide"; -// hooks -import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; -import { SettingsHeading } from "@/components/settings/heading"; -import { useWorkspace } from "@/hooks/store/use-workspace"; -import { useUserPermissions } from "@/hooks/store/user"; - -function ImportsPage() { - // router - // store hooks - const { currentWorkspace } = useWorkspace(); - const { allowPermissions } = useUserPermissions(); - // derived values - const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); - const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined; - - if (!isAdmin) return ; - - return ( - - -
- - -
-
- ); -} - -export default observer(ImportsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx index 8e4b1b1ea7..91043f17ae 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx @@ -4,8 +4,7 @@ import useSWR from "swr"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { PageHead } from "@/components/core/page-title"; -import { SingleIntegrationCard } from "@/components/integration"; -import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SingleIntegrationCard } from "@/components/integration/single-integration-card"; import { IntegrationAndImportExportBanner } from "@/components/ui/integration-and-import-export-banner"; import { IntegrationsSettingsLoader } from "@/components/ui/loader/settings/integration"; // constants @@ -33,7 +32,7 @@ function WorkspaceIntegrationsPage() { if (!isAdmin) return ; return ( - + <>
@@ -47,7 +46,7 @@ function WorkspaceIntegrationsPage() { )}
-
+ ); } diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx index 9eb72a7829..d392508ee3 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx @@ -4,14 +4,14 @@ import { Outlet } from "react-router"; // components import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper"; -import { SettingsMobileNav } from "@/components/settings/mobile"; +import { SettingsMobileNav } from "@/components/settings/mobile/nav"; // plane imports import { WORKSPACE_SETTINGS_ACCESS } from "@plane/constants"; import type { EUserWorkspaceRoles } from "@plane/types"; +// components +import { WorkspaceSettingsSidebarRoot } from "@/components/settings/workspace/sidebar"; // hooks import { useUserPermissions } from "@/hooks/store/user"; -// local components -import { WorkspaceSettingsSidebar } from "./sidebar"; import type { Route } from "./+types/layout"; @@ -34,18 +34,18 @@ const WorkspaceSettingLayout = observer(function WorkspaceSettingLayout({ params return ( <>
{workspaceUserInfo && !isAuthorized ? ( ) : ( -
-
{}
-
- +
+
+
+
)}
diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/header.tsx new file mode 100644 index 0000000000..72a12a71d2 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { WORKSPACE_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; + +export const MembersWorkspaceSettingsHeader = observer(function MembersWorkspaceSettingsHeader() { + // plane hooks + const { t } = useTranslation(); + // derived values + const settingsDetails = WORKSPACE_SETTINGS.members; + const Icon = WORKSPACE_SETTINGS_ICONS.members; + + return ( + + + } + /> + } + /> + +
+ } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx index 7f8e32fec1..9c869de32a 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx @@ -13,7 +13,6 @@ import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view import { CountChip } from "@/components/common/count-chip"; import { PageHead } from "@/components/core/page-title"; import { MemberListFiltersDropdown } from "@/components/project/dropdowns/filters/member-list"; -import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; import { WorkspaceMembersList } from "@/components/workspace/settings/members-list"; // hooks import { useMember } from "@/hooks/store/use-member"; @@ -22,7 +21,10 @@ import { useUserPermissions } from "@/hooks/store/user"; // plane web components import { BillingActionsButton } from "@/plane-web/components/workspace/billing/billing-actions-button"; import { SendWorkspaceInvitationModal, MembersActivityButton } from "@/plane-web/components/workspace/members"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +// local imports import type { Route } from "./+types/page"; +import { MembersWorkspaceSettingsHeader } from "./header"; const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsPage({ params }: Route.ComponentProps) { // states @@ -93,7 +95,7 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP } return ( - + } hugging>
-

+

{t("workspace_settings.settings.members.title")} {workspaceMemberIds && workspaceMemberIds.length > 0 && ( diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx deleted file mode 100644 index 822ba83f98..0000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; -import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -// hooks -import { useUserPermissions } from "@/hooks/store/user"; -import { useAppRouter } from "@/hooks/use-app-router"; -// plane web helpers -import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; - -export const MobileWorkspaceSettingsTabs = observer(function MobileWorkspaceSettingsTabs() { - const router = useAppRouter(); - const { workspaceSlug } = useParams(); - const pathname = usePathname(); - const { t } = useTranslation(); - // mobx store - const { allowPermissions } = useUserPermissions(); - - return ( -
- {WORKSPACE_SETTINGS_LINKS.map( - (item, index) => - shouldRenderSettingLink(workspaceSlug.toString(), item.key) && - allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && ( -
router.push(`/${workspaceSlug}${item.href}`)} - > - {t(item.i18n_label)} -
- ) - )} -
- ); -}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx index f7aa43fdea..3025c521b9 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx @@ -7,8 +7,10 @@ import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; +// local imports +import { GeneralWorkspaceSettingsHeader } from "./header"; -function WorkspaceSettingsPage() { +function GeneralWorkspaceSettingsPage() { // store hooks const { currentWorkspace } = useWorkspace(); const { t } = useTranslation(); @@ -18,11 +20,11 @@ function WorkspaceSettingsPage() { : undefined; return ( - + }> ); } -export default observer(WorkspaceSettingsPage); +export default observer(GeneralWorkspaceSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx deleted file mode 100644 index d4f6aed1a6..0000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useParams, usePathname } from "next/navigation"; -import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react"; -import type { LucideIcon } from "lucide-react"; -// plane imports -import { - EUserPermissionsLevel, - EUserPermissions, - GROUPED_WORKSPACE_SETTINGS, - WORKSPACE_SETTINGS_CATEGORIES, - WORKSPACE_SETTINGS_CATEGORY, -} from "@plane/constants"; -import type { WORKSPACE_SETTINGS } from "@plane/constants"; -import type { ISvgIcons } from "@plane/propel/icons"; -import type { EUserWorkspaceRoles } from "@plane/types"; -// components -import { SettingsSidebar } from "@/components/settings/sidebar"; -// hooks -import { useUserPermissions } from "@/hooks/store/user"; -// plane web imports -import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; - -export const WORKSPACE_SETTINGS_ICONS: Record> = { - general: Building, - members: Users, - export: ArrowUpToLine, - "billing-and-plans": CreditCard, - webhooks: Webhook, -}; - -export function WorkspaceActionIcons({ type, size, className }: { type: string; size?: number; className?: string }) { - if (type === undefined) return null; - const Icon = WORKSPACE_SETTINGS_ICONS[type as keyof typeof WORKSPACE_SETTINGS_ICONS]; - if (!Icon) return null; - return ; -} - -type TWorkspaceSettingsSidebarProps = { - isMobile?: boolean; -}; - -export function WorkspaceSettingsSidebar(props: TWorkspaceSettingsSidebarProps) { - const { isMobile = false } = props; - // router - const pathname = usePathname(); - const { workspaceSlug } = useParams(); // store hooks - const { allowPermissions } = useUserPermissions(); - const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); - - return ( - - isAdmin || ![WORKSPACE_SETTINGS_CATEGORY.FEATURES, WORKSPACE_SETTINGS_CATEGORY.DEVELOPER].includes(category) - )} - groupedSettings={GROUPED_WORKSPACE_SETTINGS} - workspaceSlug={workspaceSlug.toString()} - isActive={(data: { href: string }) => - data.href === "/settings" - ? pathname === `/${workspaceSlug}${data.href}/` - : new RegExp(`^/${workspaceSlug}${data.href}/`).test(pathname) - } - shouldRender={(data: { key: string; access?: EUserWorkspaceRoles[] | undefined }) => - data.access - ? shouldRenderSettingLink(workspaceSlug.toString(), data.key) && - allowPermissions(data.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) - : false - } - actionIcons={WorkspaceActionIcons} - /> - ); -} diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/header.tsx new file mode 100644 index 0000000000..efd2f9fe78 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { WORKSPACE_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; + +export const WebhookDetailsWorkspaceSettingsHeader = observer(function WebhookDetailsWorkspaceSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = WORKSPACE_SETTINGS.webhooks; + const Icon = WORKSPACE_SETTINGS_ICONS.webhooks; + + return ( + + + } + /> + } + /> + +

+ } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx index 441ae6662f..26f506275c 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx @@ -14,7 +14,9 @@ import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/compone import { useWebhook } from "@/hooks/store/use-webhook"; import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUserPermissions } from "@/hooks/store/user"; +// local imports import type { Route } from "./+types/page"; +import { WebhookDetailsWorkspaceSettingsHeader } from "./header"; function WebhookDetailsPage({ params }: Route.ComponentProps) { // states @@ -87,7 +89,7 @@ function WebhookDetailsPage({ params }: Route.ComponentProps) { ); return ( - + }> setDeleteWebhookModal(false)} />
diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/header.tsx new file mode 100644 index 0000000000..1136eb2b87 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { WORKSPACE_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; + +export const WebhooksWorkspaceSettingsHeader = observer(function WebhooksWorkspaceSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = WORKSPACE_SETTINGS.webhooks; + const Icon = WORKSPACE_SETTINGS_ICONS.webhooks; + + return ( + + + } + /> + } + /> + +
+ } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx index 41ef5af4f6..096403de22 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx @@ -4,19 +4,22 @@ import useSWR from "swr"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; // components import { EmptyStateCompact } from "@plane/propel/empty-state"; import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { PageHead } from "@/components/core/page-title"; -import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; import { SettingsHeading } from "@/components/settings/heading"; import { WebhookSettingsLoader } from "@/components/ui/loader/settings/web-hook"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks"; // hooks import { useWebhook } from "@/hooks/store/use-webhook"; import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUserPermissions } from "@/hooks/store/user"; +// local imports import type { Route } from "./+types/page"; +import { WebhooksWorkspaceSettingsHeader } from "./header"; function WebhooksListPage({ params }: Route.ComponentProps) { // states @@ -53,7 +56,7 @@ function WebhooksListPage({ params }: Route.ComponentProps) { if (!webhooks) return ; return ( - + }>
{ - setShowCreateWebhookModal(true); - }, - }} + control={ + + } /> {Object.keys(webhooks).length > 0 ? ( -
+
) : ( diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx deleted file mode 100644 index c071ef4c35..0000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useState } from "react"; -import { observer } from "mobx-react"; -import useSWR from "swr"; -// plane imports -import { useTranslation } from "@plane/i18n"; -// component -import { EmptyStateCompact } from "@plane/propel/empty-state"; -import { APITokenService } from "@plane/services"; -import { CreateApiTokenModal } from "@/components/api-token/modal/create-token-modal"; -import { ApiTokenListItem } from "@/components/api-token/token-list-item"; -import { PageHead } from "@/components/core/page-title"; -import { SettingsHeading } from "@/components/settings/heading"; -import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token"; -import { API_TOKENS_LIST } from "@/constants/fetch-keys"; -// store hooks -import { useWorkspace } from "@/hooks/store/use-workspace"; - -const apiTokenService = new APITokenService(); - -function ApiTokensPage() { - // states - const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false); - // router - // plane hooks - const { t } = useTranslation(); - // store hooks - const { currentWorkspace } = useWorkspace(); - - const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list()); - - const pageTitle = currentWorkspace?.name - ? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}` - : undefined; - - if (!tokens) { - return ; - } - - return ( -
- - setIsCreateTokenModalOpen(false)} /> -
- {tokens.length > 0 ? ( - <> - { - setIsCreateTokenModalOpen(true); - }, - }} - /> -
- {tokens.map((token) => ( - - ))} -
- - ) : ( -
- { - setIsCreateTokenModalOpen(true); - }, - }} - /> - - { - setIsCreateTokenModalOpen(true); - }, - }, - ]} - align="start" - rootClassName="py-20" - /> -
- )} -
-
- ); -} - -export default observer(ApiTokensPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx deleted file mode 100644 index 25d737300b..0000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { observer } from "mobx-react"; -import { usePathname } from "next/navigation"; -import { Outlet } from "react-router"; -// components -import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; -import { getProfileActivePath } from "@/components/settings/helper"; -import { SettingsMobileNav } from "@/components/settings/mobile"; -// local imports -import { ProfileSidebar } from "./sidebar"; - -function ProfileSettingsLayout() { - // router - const pathname = usePathname(); - - return ( - <> - -
-
- -
-
- - - -
-
- - ); -} - -export default observer(ProfileSettingsLayout); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx deleted file mode 100644 index f3098f675a..0000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { observer } from "mobx-react"; -// plane imports -import { useTranslation } from "@plane/i18n"; -// components -import { PageHead } from "@/components/core/page-title"; -import { PreferencesList } from "@/components/preferences/list"; -import { LanguageTimezone } from "@/components/profile/preferences/language-timezone"; -import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; -import { SettingsHeading } from "@/components/settings/heading"; -// hooks -import { useUserProfile } from "@/hooks/store/user"; - -const ProfileAppearancePage = observer(() => { - const { t } = useTranslation(); - // hooks - const { data: userProfile } = useUserProfile(); - - if (!userProfile) return <>; - return ( - <> - -
-
- - -
-
- - -
-
- - ); -}); - -export default ProfileAppearancePage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx deleted file mode 100644 index cd03a7ca01..0000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { useState } from "react"; -import { observer } from "mobx-react"; -import { Controller, useForm } from "react-hook-form"; -import { Eye, EyeOff } from "lucide-react"; -// plane imports -import { E_PASSWORD_STRENGTH } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/propel/button"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { Input, PasswordStrengthIndicator } from "@plane/ui"; -import { getPasswordStrength } from "@plane/utils"; -// components -import { PageHead } from "@/components/core/page-title"; -import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; -// 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"; - -export interface FormValues { - old_password: string; - new_password: string; - confirm_password: string; -} - -const defaultValues: FormValues = { - old_password: "", - new_password: "", - confirm_password: "", -}; - -const authService = new AuthService(); - -const defaultShowPassword = { - oldPassword: false, - password: false, - confirmPassword: false, -}; - -function SecurityPage() { - // store - const { data: currentUser, changePassword } = useUser(); - // states - const [showPassword, setShowPassword] = useState(defaultShowPassword); - const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); - const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); - - // use form - const { - control, - handleSubmit, - watch, - formState: { errors, isSubmitting }, - reset, - } = useForm({ defaultValues }); - // derived values - const oldPassword = watch("old_password"); - const password = watch("new_password"); - const confirmPassword = watch("confirm_password"); - const oldPasswordRequired = !currentUser?.is_password_autoset; - // i18n - const { t } = useTranslation(); - - const isNewPasswordSameAsOldPassword = oldPassword !== "" && password !== "" && password === oldPassword; - - const handleShowPassword = (key: keyof typeof showPassword) => - setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); - - const handleChangePassword = async (formData: FormValues) => { - const { old_password, new_password } = formData; - try { - const csrfToken = await authService.requestCSRFToken().then((data) => data?.csrf_token); - if (!csrfToken) throw new Error("csrf token not found"); - - await changePassword(csrfToken, { - ...(oldPasswordRequired && { old_password }), - new_password, - }); - - reset(defaultValues); - setShowPassword(defaultShowPassword); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: t("auth.common.password.toast.change_password.success.title"), - message: t("auth.common.password.toast.change_password.success.message"), - }); - } catch (error: unknown) { - let errorInfo = undefined; - if (error instanceof Error) { - const err = error as Error & { error_code?: string }; - const code = err.error_code?.toString(); - errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined; - } - - setToast({ - type: TOAST_TYPE.ERROR, - title: errorInfo?.title ?? t("auth.common.password.toast.error.title"), - message: - typeof errorInfo?.message === "string" ? errorInfo.message : t("auth.common.password.toast.error.message"), - }); - } - }; - - const isButtonDisabled = - getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID || - (oldPasswordRequired && oldPassword.trim() === "") || - password.trim() === "" || - confirmPassword.trim() === "" || - password !== confirmPassword || - password === oldPassword; - - const passwordSupport = password.length > 0 && - getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && ( - - ); - - const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; - - return ( - <> - - -
-
- {oldPasswordRequired && ( -
-

{t("auth.common.password.current_password.label")}

-
- ( - - )} - /> - {showPassword?.oldPassword ? ( - handleShowPassword("oldPassword")} - /> - ) : ( - handleShowPassword("oldPassword")} - /> - )} -
- {errors.old_password && ( - {errors.old_password.message} - )} -
- )} -
-

{t("auth.common.password.new_password.label")}

-
- ( - setIsPasswordInputFocused(true)} - onBlur={() => setIsPasswordInputFocused(false)} - /> - )} - /> - {showPassword?.password ? ( - handleShowPassword("password")} - /> - ) : ( - handleShowPassword("password")} - /> - )} -
- {passwordSupport} - {isNewPasswordSameAsOldPassword && !isPasswordInputFocused && ( - - {t("new_password_must_be_different_from_old_password")} - - )} -
-
-

{t("auth.common.password.confirm_password.label")}

-
- ( - setIsRetryPasswordInputFocused(true)} - onBlur={() => setIsRetryPasswordInputFocused(false)} - /> - )} - /> - {showPassword?.confirmPassword ? ( - handleShowPassword("confirmPassword")} - /> - ) : ( - handleShowPassword("confirmPassword")} - /> - )} -
- {!!confirmPassword && password !== confirmPassword && renderPasswordMatchError && ( - {t("auth.common.password.errors.match")} - )} -
-
- -
- -
-
- - ); -} - -export default observer(SecurityPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx deleted file mode 100644 index 4088ec4ab3..0000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; -import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks } from "lucide-react"; -// plane imports -import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants"; -import { LockIcon } from "@plane/propel/icons"; -import { getFileURL } from "@plane/utils"; -// components -import { SettingsSidebar } from "@/components/settings/sidebar"; -// hooks -import { useUser } from "@/hooks/store/user"; - -const ICONS = { - profile: CircleUser, - security: LockIcon, - activity: Activity, - preferences: Settings2, - notifications: Bell, - "api-tokens": KeyRound, - connections: Blocks, -}; - -export function ProjectActionIcons({ type, size, className }: { type: string; size?: number; className?: string }) { - if (type === undefined) return null; - const Icon = ICONS[type as keyof typeof ICONS]; - if (!Icon) return null; - return ; -} - -type TProfileSidebarProps = { - isMobile?: boolean; -}; - -export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSidebarProps) { - const { isMobile = false } = props; - // router - const pathname = usePathname(); - const { workspaceSlug } = useParams(); - // store hooks - const { data: currentUser } = useUser(); - - return ( - pathname === `/${workspaceSlug}${data.href}/`} - customHeader={ -
-
- {!currentUser?.avatar_url || currentUser?.avatar_url === "" ? ( -
- -
- ) : ( -
- {currentUser?.display_name} -
- )} -
-
-
{currentUser?.display_name}
-
{currentUser?.email}
-
-
- } - actionIcons={ProjectActionIcons} - shouldRender - /> - ); -}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/header.tsx new file mode 100644 index 0000000000..e736d892bd --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const AutomationsProjectSettingsHeader = observer(function AutomationsProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.automations; + const Icon = PROJECT_SETTINGS_ICONS.automations; + + return ( + + + } + /> + } + /> + +
+ } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx index 3607757553..657a78c961 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx @@ -1,21 +1,22 @@ import { observer } from "mobx-react"; +// plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IProject } from "@plane/types"; -// ui -// components import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; import { PageHead } from "@/components/core/page-title"; -// hooks import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; import { SettingsHeading } from "@/components/settings/heading"; +// hooks import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; // plane web imports import { CustomAutomationsRoot } from "@/plane-web/components/automations/root"; +// local imports import type { Route } from "./+types/page"; +import { AutomationsProjectSettingsHeader } from "./header"; function AutomationSettingsPage({ params }: Route.ComponentProps) { // router @@ -51,15 +52,17 @@ function AutomationSettingsPage({ params }: Route.ComponentProps) { } return ( - + } hugging>
- - +
+ + +
diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/header.tsx new file mode 100644 index 0000000000..34086c6b06 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const EstimatesProjectSettingsHeader = observer(function EstimatesProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.estimates; + const Icon = PROJECT_SETTINGS_ICONS.estimates; + + return ( + + + } + /> + } + /> + +
+ } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx index 2d446ae7ca..44fc12c7d4 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx @@ -4,11 +4,13 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { PageHead } from "@/components/core/page-title"; import { EstimateRoot } from "@/components/estimates"; -// hooks import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +// hooks import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; +// local imports import type { Route } from "./+types/page"; +import { EstimatesProjectSettingsHeader } from "./header"; function EstimatesSettingsPage({ params }: Route.ComponentProps) { const { workspaceSlug, projectId } = params; @@ -25,7 +27,7 @@ function EstimatesSettingsPage({ params }: Route.ComponentProps) { } return ( - + }>
diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/header.tsx new file mode 100644 index 0000000000..756eaac160 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const FeaturesCyclesProjectSettingsHeader = observer(function FeaturesCyclesProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.features_cycles; + const Icon = PROJECT_SETTINGS_ICONS.features_cycles; + + return ( + + + } + /> + } + /> + +
+ } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx new file mode 100644 index 0000000000..a943c15096 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx @@ -0,0 +1,58 @@ +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { FeaturesCyclesProjectSettingsHeader } from "./header"; +import { SettingsHeading } from "@/components/settings/heading"; + +function FeaturesCyclesSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); + // translation + const { t } = useTranslation(); + // derived values + const pageTitle = currentProjectDetails?.name + ? `${currentProjectDetails?.name} settings - ${t("project_settings.features.cycles.short_title")}` + : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + }> + +
+ +
+ +
+
+
+ ); +} + +export default observer(FeaturesCyclesSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/header.tsx new file mode 100644 index 0000000000..0bbff98579 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const FeaturesIntakeProjectSettingsHeader = observer(function FeaturesIntakeProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.features_intake; + const Icon = PROJECT_SETTINGS_ICONS.features_intake; + + return ( + + + } + /> + } + /> + +
+ } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/page.tsx new file mode 100644 index 0000000000..a8ac0adebf --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/page.tsx @@ -0,0 +1,58 @@ +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { FeaturesIntakeProjectSettingsHeader } from "./header"; + +function FeaturesIntakeSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); + // translation + const { t } = useTranslation(); + // derived values + const pageTitle = currentProjectDetails?.name + ? `${currentProjectDetails?.name} settings - ${t("project_settings.features.intake.short_title")}` + : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + }> + +
+ +
+ +
+
+
+ ); +} + +export default observer(FeaturesIntakeSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/header.tsx new file mode 100644 index 0000000000..397a2b6a48 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const FeaturesModulesProjectSettingsHeader = observer(function FeaturesModulesProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.features_modules; + const Icon = PROJECT_SETTINGS_ICONS.features_modules; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/page.tsx new file mode 100644 index 0000000000..7f5f540fe7 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/page.tsx @@ -0,0 +1,58 @@ +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { FeaturesModulesProjectSettingsHeader } from "./header"; + +function FeaturesModulesSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); + // translation + const { t } = useTranslation(); + // derived values + const pageTitle = currentProjectDetails?.name + ? `${currentProjectDetails?.name} settings - ${t("project_settings.features.modules.short_title")}` + : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + }> + +
+ +
+ +
+
+
+ ); +} + +export default observer(FeaturesModulesSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx deleted file mode 100644 index 6db9c4bd09..0000000000 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { observer } from "mobx-react"; -// components -import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; -import { PageHead } from "@/components/core/page-title"; -import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; -// hooks -import { useProject } from "@/hooks/store/use-project"; -import { useUserPermissions } from "@/hooks/store/user"; -import { ProjectFeaturesList } from "@/plane-web/components/projects/settings/features-list"; -import type { Route } from "./+types/page"; - -function FeaturesSettingsPage({ params }: Route.ComponentProps) { - const { workspaceSlug, projectId } = params; - // store - const { workspaceUserInfo, allowPermissions } = useUserPermissions(); - - const { currentProjectDetails } = useProject(); - // derived values - const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined; - const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); - - if (workspaceUserInfo && !canPerformProjectAdminActions) { - return ; - } - - return ( - - -
- -
-
- ); -} - -export default observer(FeaturesSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/header.tsx new file mode 100644 index 0000000000..9ee3db684f --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const FeaturesPagesProjectSettingsHeader = observer(function FeaturesPagesProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.features_pages; + const Icon = PROJECT_SETTINGS_ICONS.features_pages; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/page.tsx new file mode 100644 index 0000000000..05ed1e6cb0 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/page.tsx @@ -0,0 +1,58 @@ +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { FeaturesPagesProjectSettingsHeader } from "./header"; + +function FeaturesPagesSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); + // translation + const { t } = useTranslation(); + // derived values + const pageTitle = currentProjectDetails?.name + ? `${currentProjectDetails?.name} settings - ${t("project_settings.features.pages.short_title")}` + : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + }> + +
+ +
+ +
+
+
+ ); +} + +export default observer(FeaturesPagesSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/header.tsx new file mode 100644 index 0000000000..4ca18074a5 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const FeaturesViewsProjectSettingsHeader = observer(function FeaturesViewsProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.features_views; + const Icon = PROJECT_SETTINGS_ICONS.features_views; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/page.tsx new file mode 100644 index 0000000000..39f46d11f2 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/page.tsx @@ -0,0 +1,58 @@ +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +import { ProjectSettingsFeatureControlItem } from "@/components/settings/project/content/feature-control-item"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import type { Route } from "./+types/page"; +import { FeaturesViewsProjectSettingsHeader } from "./header"; + +function FeaturesViewsSettingsPage({ params }: Route.ComponentProps) { + const { workspaceSlug, projectId } = params; + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); + // translation + const { t } = useTranslation(); + // derived values + const pageTitle = currentProjectDetails?.name + ? `${currentProjectDetails?.name} settings - ${t("project_settings.features.views.short_title")}` + : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + return ( + }> + +
+ +
+ +
+
+
+ ); +} + +export default observer(FeaturesViewsSettingsPage); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/header.tsx new file mode 100644 index 0000000000..ba3e7c5fd1 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const GeneralProjectSettingsHeader = observer(function GeneralProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.general; + const Icon = PROJECT_SETTINGS_ICONS.general; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/header.tsx new file mode 100644 index 0000000000..0138b0ea84 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const LabelsProjectSettingsHeader = observer(function LabelsProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.labels; + const Icon = PROJECT_SETTINGS_ICONS.labels; + + return ( + + + } + /> + } + /> + + + } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx index 812b583023..5b2416f9ec 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx @@ -7,10 +7,12 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { PageHead } from "@/components/core/page-title"; import { ProjectSettingsLabelList } from "@/components/labels"; -// hooks import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +// hooks import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import { LabelsProjectSettingsHeader } from "./header"; function LabelsSettingsPage() { // store hooks @@ -45,9 +47,9 @@ function LabelsSettingsPage() { } return ( - + }> -
+
diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx index 8e8c09064d..8381edbe3d 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx @@ -3,12 +3,12 @@ import { usePathname } from "next/navigation"; import { Outlet } from "react-router"; // components import { getProjectActivePath } from "@/components/settings/helper"; -import { SettingsMobileNav } from "@/components/settings/mobile"; -import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar"; +import { SettingsMobileNav } from "@/components/settings/mobile/nav"; // plane web imports import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; // types import type { Route } from "./+types/layout"; +import { ProjectSettingsSidebarRoot } from "@/components/settings/project/sidebar"; function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) { const { workspaceSlug, projectId } = params; @@ -17,14 +17,19 @@ function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) { return ( <> - -
-
{projectId && }
- -
- + } + activePath={getProjectActivePath(pathname) || ""} + /> +
+
+
+
- + + + +
); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/header.tsx new file mode 100644 index 0000000000..c9a2348fb0 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const MembersProjectSettingsHeader = observer(function MembersProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.members; + const Icon = PROJECT_SETTINGS_ICONS.members; + + return ( + + + } + /> + } + /> + +
+ } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx index 3bdf8e9991..21141c14ca 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx @@ -5,17 +5,19 @@ import { useTranslation } from "@plane/i18n"; // components import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { PageHead } from "@/components/core/page-title"; -// hooks import { ProjectMemberList } from "@/components/project/member-list"; import { ProjectSettingsMemberDefaults } from "@/components/project/project-settings-member-defaults"; import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; import { SettingsHeading } from "@/components/settings/heading"; +// hooks import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; // plane web imports import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list"; import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings"; +// local imports import type { Route } from "./+types/page"; +import { MembersProjectSettingsHeader } from "./header"; function MembersSettingsPage({ params }: Route.ComponentProps) { // router @@ -39,7 +41,7 @@ function MembersSettingsPage({ params }: Route.ComponentProps) { } return ( - + } hugging> diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx index ea0486fa37..00b9d7b534 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx @@ -1,25 +1,20 @@ -import { useState } from "react"; import { observer } from "mobx-react"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; // components import { PageHead } from "@/components/core/page-title"; -import { DeleteProjectModal } from "@/components/project/delete-project-modal"; import { ProjectDetailsForm } from "@/components/project/form"; import { ProjectDetailsFormLoader } from "@/components/project/form-loader"; -import { ArchiveRestoreProjectModal } from "@/components/project/settings/archive-project/archive-restore-modal"; -import { ArchiveProjectSelection } from "@/components/project/settings/archive-project/selection"; -import { DeleteProjectSection } from "@/components/project/settings/delete-project-section"; import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; +// local imports import type { Route } from "./+types/page"; +import { GeneralProjectSettingsHeader } from "./header"; +import { GeneralProjectSettingsControlSection } from "@/components/project/settings/control-section"; function ProjectSettingsPage({ params }: Route.ComponentProps) { - // states - const [selectProject, setSelectedProject] = useState(null); - const [archiveProject, setArchiveProject] = useState(false); // router const { workspaceSlug, projectId } = params; // store hooks @@ -31,25 +26,8 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) { const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined; return ( - + }> - {currentProjectDetails && ( - <> - setArchiveProject(false)} - archive - /> - setSelectedProject(null)} - /> - - )} -
{currentProjectDetails ? ( )} - - {isAdmin && currentProjectDetails && ( - <> - setArchiveProject(true)} - /> - setSelectedProject(currentProjectDetails.id ?? null)} - /> - - )} + {isAdmin && }
); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/header.tsx new file mode 100644 index 0000000000..f69d22922b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/header.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { PROJECT_SETTINGS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +import { SettingsPageHeader } from "@/components/settings/page-header"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; + +export const StatesProjectSettingsHeader = observer(function StatesProjectSettingsHeader() { + // translation + const { t } = useTranslation(); + // derived values + const settingsDetails = PROJECT_SETTINGS.states; + const Icon = PROJECT_SETTINGS_ICONS.states; + + return ( + + + } + /> + } + /> + +
+ } + /> + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx index 31d8ccbc1a..ff3309d458 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx @@ -5,12 +5,14 @@ import { useTranslation } from "@plane/i18n"; import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { PageHead } from "@/components/core/page-title"; import { ProjectStateRoot } from "@/components/project-states"; -// hook import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; import { SettingsHeading } from "@/components/settings/heading"; +// hook import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; +// local imports import type { Route } from "./+types/page"; +import { StatesProjectSettingsHeader } from "./header"; function StatesSettingsPage({ params }: Route.ComponentProps) { const { workspaceSlug, projectId } = params; @@ -33,14 +35,16 @@ function StatesSettingsPage({ params }: Route.ComponentProps) { } return ( - + }>
- +
+ +
); diff --git a/apps/web/app/(all)/[workspaceSlug]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/layout.tsx index 0e489644d4..854e6bbaf2 100644 --- a/apps/web/app/(all)/[workspaceSlug]/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/layout.tsx @@ -2,14 +2,19 @@ import { Outlet } from "react-router"; import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/content-wrapper"; import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail"; +import { GlobalModals } from "@/plane-web/components/common/modal/global"; import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; +import type { Route } from "./+types/layout"; + +export default function WorkspaceLayout(props: Route.ComponentProps) { + const { workspaceSlug } = props.params; -export default function WorkspaceLayout() { return ( + diff --git a/apps/web/app/(all)/profile/activity/page.tsx b/apps/web/app/(all)/profile/activity/page.tsx deleted file mode 100644 index e3956258ff..0000000000 --- a/apps/web/app/(all)/profile/activity/page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useState } from "react"; -import { observer } from "mobx-react"; -import { useTheme } from "next-themes"; -// plane imports -import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/propel/button"; -// assets -import darkActivityAsset from "@/app/assets/empty-state/profile/activity-dark.webp?url"; -import lightActivityAsset from "@/app/assets/empty-state/profile/activity-light.webp?url"; -// components -import { PageHead } from "@/components/core/page-title"; -import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; -import { ProfileActivityListPage } from "@/components/profile/activity/profile-activity-list"; -import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; -import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; - -const PER_PAGE = 100; - -function ProfileActivityPage() { - // states - const [pageCount, setPageCount] = useState(1); - const [totalPages, setTotalPages] = useState(0); - const [resultsCount, setResultsCount] = useState(0); - const [isEmpty, setIsEmpty] = useState(false); - // theme hook - const { resolvedTheme } = useTheme(); - // plane hooks - const { t } = useTranslation(); - // derived values - const resolvedPath = resolvedTheme === "light" ? lightActivityAsset : darkActivityAsset; - - const updateTotalPages = (count: number) => setTotalPages(count); - - const updateResultsCount = (count: number) => setResultsCount(count); - - const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty); - - const handleLoadMore = () => setPageCount((prev) => prev + 1); - - const activityPages: React.ReactNode[] = []; - for (let i = 0; i < pageCount; i++) - activityPages.push( - - ); - - const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0; - - if (isEmpty) { - return ( - - ); - } - - return ( - <> - - - - {activityPages} - {isLoadMoreVisible && ( -
- -
- )} -
- - ); -} - -export default observer(ProfileActivityPage); diff --git a/apps/web/app/(all)/profile/appearance/page.tsx b/apps/web/app/(all)/profile/appearance/page.tsx deleted file mode 100644 index d0d05588d8..0000000000 --- a/apps/web/app/(all)/profile/appearance/page.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { observer } from "mobx-react"; -import { useTheme } from "next-themes"; -// plane imports -import type { I_THEME_OPTION } from "@plane/constants"; -import { THEME_OPTIONS } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { setPromiseToast } from "@plane/propel/toast"; -import { applyCustomTheme } from "@plane/utils"; -// components -import { LogoSpinner } from "@/components/common/logo-spinner"; -import { PageHead } from "@/components/core/page-title"; -import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector"; -import { ThemeSwitch } from "@/components/core/theme/theme-switch"; -import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; -import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; -// hooks -import { useUserProfile } from "@/hooks/store/user"; - -function ProfileAppearancePage() { - // store hooks - const { data: userProfile, updateUserTheme } = useUserProfile(); - // theme - const { setTheme } = useTheme(); - // translation - const { t } = useTranslation(); - // derived values - const currentTheme = useMemo(() => { - const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme); - return userThemeOption || null; - }, [userProfile?.theme?.theme]); - - const handleThemeChange = useCallback( - async (themeOption: I_THEME_OPTION) => { - setTheme(themeOption.value); - - // If switching to custom theme and user has saved custom colors, apply them immediately - if ( - themeOption.value === "custom" && - userProfile?.theme?.primary && - userProfile?.theme?.background && - userProfile?.theme?.darkPalette !== undefined - ) { - applyCustomTheme( - userProfile.theme.primary, - userProfile.theme.background, - userProfile.theme.darkPalette ? "dark" : "light" - ); - } - - const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value }); - setPromiseToast(updateCurrentUserThemePromise, { - loading: "Updating theme...", - success: { - title: "Theme updated", - message: () => "Reloading to apply changes...", - }, - error: { - title: "Error!", - message: () => "Failed to update theme. Please try again.", - }, - }); - // Wait for the promise to resolve, then reload after showing toast - try { - await updateCurrentUserThemePromise; - window.location.reload(); - } catch (error) { - // Error toast already shown by setPromiseToast - console.error("Error updating theme:", error); - } - }, - [setTheme, updateUserTheme, userProfile] - ); - - return ( - <> - - {userProfile ? ( - - -
-
-

{t("theme")}

-

{t("select_or_customize_your_interface_color_scheme")}

-
-
- -
-
- {userProfile?.theme?.theme === "custom" && } -
- ) : ( -
- -
- )} - - ); -} - -export default observer(ProfileAppearancePage); diff --git a/apps/web/app/(all)/profile/notifications/page.tsx b/apps/web/app/(all)/profile/notifications/page.tsx deleted file mode 100644 index 725117e5c2..0000000000 --- a/apps/web/app/(all)/profile/notifications/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import useSWR from "swr"; -// components -import { useTranslation } from "@plane/i18n"; -import { PageHead } from "@/components/core/page-title"; -import { EmailNotificationForm } from "@/components/profile/notification/email-notification-form"; -import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; -import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; -import { EmailSettingsLoader } from "@/components/ui/loader/settings/email"; -// services -import { UserService } from "@/services/user.service"; - -const userService = new UserService(); - -export default function ProfileNotificationPage() { - const { t } = useTranslation(); - // fetching user email notification settings - const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => - userService.currentUserEmailNotificationSettings() - ); - - if (!data || isLoading) { - return ; - } - - return ( - <> - - - - - - - ); -} diff --git a/apps/web/app/(all)/profile/page.tsx b/apps/web/app/(all)/profile/page.tsx deleted file mode 100644 index 9b6f8f083c..0000000000 --- a/apps/web/app/(all)/profile/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { observer } from "mobx-react"; -// plane imports -import { useTranslation } from "@plane/i18n"; -// components -import { LogoSpinner } from "@/components/common/logo-spinner"; -import { PageHead } from "@/components/core/page-title"; -import { ProfileForm } from "@/components/profile/form"; -import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; -// hooks -import { useUser } from "@/hooks/store/user"; - -function ProfileSettingsPage() { - const { t } = useTranslation(); - // store hooks - const { data: currentUser, userProfile } = useUser(); - - if (!currentUser) - return ( -
- -
- ); - - return ( - <> - - - - - - ); -} - -export default observer(ProfileSettingsPage); diff --git a/apps/web/app/(all)/profile/sidebar.tsx b/apps/web/app/(all)/profile/sidebar.tsx deleted file mode 100644 index c4ded1df24..0000000000 --- a/apps/web/app/(all)/profile/sidebar.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -// icons -import { LogOut, MoveLeft, Activity, Bell, CircleUser, KeyRound, Settings2, CirclePlus, Mails } from "lucide-react"; -// plane imports -import { PROFILE_ACTION_LINKS } from "@plane/constants"; -import { useOutsideClickDetector } from "@plane/hooks"; -import { useTranslation } from "@plane/i18n"; -import { ChevronLeftIcon } from "@plane/propel/icons"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { Tooltip } from "@plane/propel/tooltip"; -import { cn, getFileURL } from "@plane/utils"; -// components -import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation"; -// hooks -import { useAppTheme } from "@/hooks/store/use-app-theme"; -import { useWorkspace } from "@/hooks/store/use-workspace"; -import { useUser, useUserSettings } from "@/hooks/store/user"; -import { usePlatformOS } from "@/hooks/use-platform-os"; - -const WORKSPACE_ACTION_LINKS = [ - { - key: "create_workspace", - Icon: CirclePlus, - i18n_label: "create_workspace", - href: "/create-workspace", - }, - { - key: "invitations", - Icon: Mails, - i18n_label: "workspace_invites", - href: "/invitations", - }, -]; - -function ProjectActionIcons({ type, size, className = "" }: { type: string; size?: number; className?: string }) { - const icons = { - profile: CircleUser, - security: KeyRound, - activity: Activity, - preferences: Settings2, - notifications: Bell, - "api-tokens": KeyRound, - }; - - if (type === undefined) return null; - const Icon = icons[type as keyof typeof icons]; - if (!Icon) return null; - return ; -} - -export const ProfileLayoutSidebar = observer(function ProfileLayoutSidebar() { - // states - const [isSigningOut, setIsSigningOut] = useState(false); - // router - const pathname = usePathname(); - // store hooks - const { sidebarCollapsed, toggleSidebar } = useAppTheme(); - const { data: currentUser, signOut } = useUser(); - const { data: currentUserSettings } = useUserSettings(); - const { workspaces } = useWorkspace(); - const { isMobile } = usePlatformOS(); - const { t } = useTranslation(); - - const workspacesList = Object.values(workspaces ?? {}); - - // redirect url for normal mode - const redirectWorkspaceSlug = - currentUserSettings?.workspace?.last_workspace_slug || - currentUserSettings?.workspace?.fallback_workspace_slug || - ""; - - const ref = useRef(null); - - useOutsideClickDetector(ref, () => { - if (sidebarCollapsed === false) { - if (window.innerWidth < 768) { - toggleSidebar(); - } - } - }); - - useEffect(() => { - const handleResize = () => { - if (window.innerWidth <= 768) { - toggleSidebar(true); - } - }; - handleResize(); - window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - }; - }, [toggleSidebar]); - - const handleItemClick = () => { - if (window.innerWidth < 768) { - toggleSidebar(); - } - }; - - const handleSignOut = async () => { - setIsSigningOut(true); - await signOut() - .catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: t("sign_out.toast.error.title"), - message: t("sign_out.toast.error.message"), - }) - ) - .finally(() => setIsSigningOut(false)); - }; - - return ( -
-
- -
- - - - {!sidebarCollapsed && ( -

{t("profile_settings")}

- )} -
- -
- {!sidebarCollapsed && ( -
{t("your_account")}
- )} -
- {PROFILE_ACTION_LINKS.map((link) => { - if (link.key === "change-password" && currentUser?.is_password_autoset) return null; - - return ( - - - -
- - - {!sidebarCollapsed &&

{t(link.i18n_label)}

} -
-
-
- - ); - })} -
-
-
- {!sidebarCollapsed && ( -
{t("workspaces")}
- )} - {workspacesList && workspacesList.length > 0 && ( -
- {workspacesList.map((workspace) => ( - - - - {workspace?.logo_url && workspace.logo_url !== "" ? ( - Workspace Logo - ) : ( - (workspace?.name?.charAt(0) ?? "...") - )} - - {!sidebarCollapsed &&

{workspace.name}

} -
- - ))} -
- )} -
- {WORKSPACE_ACTION_LINKS.map((link) => ( - - -
- {} - {!sidebarCollapsed && t(link.i18n_label)} -
-
- - ))} -
-
-
-
- - - -
-
-
-
- ); -}); diff --git a/apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx b/apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx new file mode 100644 index 0000000000..6b481d8189 --- /dev/null +++ b/apps/web/app/(all)/settings/profile/[profileTabId]/page.tsx @@ -0,0 +1,55 @@ +import { observer } from "mobx-react"; +// plane imports +import { PROFILE_SETTINGS_TABS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { TProfileSettingsTabs } from "@plane/types"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { PageHead } from "@/components/core/page-title"; +import { ProfileSettingsContent } from "@/components/settings/profile/content"; +import { ProfileSettingsSidebarRoot } from "@/components/settings/profile/sidebar"; +// hooks +import { useUser } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +// local imports +import type { Route } from "../+types/layout"; + +function ProfileSettingsPage(props: Route.ComponentProps) { + const { profileTabId } = props.params; + // router + const router = useAppRouter(); + // store hooks + const { data: currentUser } = useUser(); + // translation + const { t } = useTranslation(); + // derived values + const isAValidTab = PROFILE_SETTINGS_TABS.includes(profileTabId as TProfileSettingsTabs); + + if (!currentUser || !isAValidTab) + return ( +
+ +
+ ); + + return ( + <> + +
+
+ router.push(`/settings/profile/${tab}`)} + /> + +
+
+ + ); +} + +export default observer(ProfileSettingsPage); diff --git a/apps/web/app/(all)/profile/layout.tsx b/apps/web/app/(all)/settings/profile/layout.tsx similarity index 56% rename from apps/web/app/(all)/profile/layout.tsx rename to apps/web/app/(all)/settings/profile/layout.tsx index f5aebbfbbb..38311cb08d 100644 --- a/apps/web/app/(all)/profile/layout.tsx +++ b/apps/web/app/(all)/settings/profile/layout.tsx @@ -1,20 +1,17 @@ -// components import { Outlet } from "react-router"; -// wrappers +// components import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider"; +// lib import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper"; -// layout -import { ProfileLayoutSidebar } from "./sidebar"; export default function ProfileSettingsLayout() { return ( <> -
- -
-
+
+
+
diff --git a/apps/web/app/routes/core.ts b/apps/web/app/routes/core.ts index ccb9d78d37..daa83c95f8 100644 --- a/apps/web/app/routes/core.ts +++ b/apps/web/app/routes/core.ts @@ -278,34 +278,6 @@ export const coreRoutes: RouteConfigEntry[] = [ ), ]), - // -------------------------------------------------------------------- - // ACCOUNT SETTINGS - // -------------------------------------------------------------------- - - layout("./(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx", [ - route(":workspaceSlug/settings/account", "./(all)/[workspaceSlug]/(settings)/settings/account/page.tsx"), - route( - ":workspaceSlug/settings/account/activity", - "./(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx" - ), - route( - ":workspaceSlug/settings/account/preferences", - "./(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx" - ), - route( - ":workspaceSlug/settings/account/notifications", - "./(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx" - ), - route( - ":workspaceSlug/settings/account/security", - "./(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx" - ), - route( - ":workspaceSlug/settings/account/api-tokens", - "./(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx" - ), - ]), - // -------------------------------------------------------------------- // PROJECT SETTINGS // -------------------------------------------------------------------- @@ -326,8 +298,24 @@ export const coreRoutes: RouteConfigEntry[] = [ ), // Project Features route( - ":workspaceSlug/settings/projects/:projectId/features", - "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx" + ":workspaceSlug/settings/projects/:projectId/features/cycles", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx" + ), + route( + ":workspaceSlug/settings/projects/:projectId/features/modules", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/modules/page.tsx" + ), + route( + ":workspaceSlug/settings/projects/:projectId/features/views", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/views/page.tsx" + ), + route( + ":workspaceSlug/settings/projects/:projectId/features/pages", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/pages/page.tsx" + ), + route( + ":workspaceSlug/settings/projects/:projectId/features/intake", + "./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/intake/page.tsx" ), // Project States route( @@ -363,12 +351,8 @@ export const coreRoutes: RouteConfigEntry[] = [ // PROFILE SETTINGS // -------------------------------------------------------------------- - layout("./(all)/profile/layout.tsx", [ - route("profile", "./(all)/profile/page.tsx"), - route("profile/activity", "./(all)/profile/activity/page.tsx"), - route("profile/appearance", "./(all)/profile/appearance/page.tsx"), - route("profile/notifications", "./(all)/profile/notifications/page.tsx"), - route("profile/security", "./(all)/profile/security/page.tsx"), + layout("./(all)/settings/profile/layout.tsx", [ + route("settings/profile/:profileTabId", "./(all)/settings/profile/[profileTabId]/page.tsx"), ]), ]), @@ -389,7 +373,7 @@ export const coreRoutes: RouteConfigEntry[] = [ route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"), // API tokens redirect: /:workspaceSlug/settings/api-tokens - // → /:workspaceSlug/settings/account/api-tokens + // → /settings/profile/api-tokens route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"), // Inbox redirect: /:workspaceSlug/projects/:projectId/inbox @@ -406,4 +390,10 @@ export const coreRoutes: RouteConfigEntry[] = [ // Register redirect route("register", "routes/redirects/core/register.tsx"), + + // Profile settings redirects + route("profile/*", "routes/redirects/core/profile-settings.tsx"), + + // Account settings redirects + route(":workspaceSlug/settings/account/*", "routes/redirects/core/workspace-account-settings.tsx"), ] satisfies RouteConfig; diff --git a/apps/web/app/routes/redirects/core/api-tokens.tsx b/apps/web/app/routes/redirects/core/api-tokens.tsx index 68007aa416..d97413084b 100644 --- a/apps/web/app/routes/redirects/core/api-tokens.tsx +++ b/apps/web/app/routes/redirects/core/api-tokens.tsx @@ -1,9 +1,7 @@ import { redirect } from "react-router"; -import type { Route } from "./+types/api-tokens"; -export const clientLoader = ({ params }: Route.ClientLoaderArgs) => { - const { workspaceSlug } = params; - throw redirect(`/${workspaceSlug}/settings/account/api-tokens/`); +export const clientLoader = () => { + throw redirect(`/settings/profile/api-tokens/`); }; export default function ApiTokens() { diff --git a/apps/web/app/routes/redirects/core/index.ts b/apps/web/app/routes/redirects/core/index.ts index efd3ae40f8..480386bf62 100644 --- a/apps/web/app/routes/redirects/core/index.ts +++ b/apps/web/app/routes/redirects/core/index.ts @@ -14,7 +14,7 @@ export const coreRedirectRoutes: RouteConfigEntry[] = [ route(":workspaceSlug/analytics", "routes/redirects/core/analytics.tsx"), // API tokens redirect: /:workspaceSlug/settings/api-tokens - // → /:workspaceSlug/settings/account/api-tokens + // → /settings/profile/api-tokens route(":workspaceSlug/settings/api-tokens", "routes/redirects/core/api-tokens.tsx"), // Inbox redirect: /:workspaceSlug/projects/:projectId/inbox diff --git a/apps/web/app/routes/redirects/core/profile-settings.tsx b/apps/web/app/routes/redirects/core/profile-settings.tsx new file mode 100644 index 0000000000..7e8a0c15a2 --- /dev/null +++ b/apps/web/app/routes/redirects/core/profile-settings.tsx @@ -0,0 +1,12 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/profile-settings"; + +export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => { + const searchParams = new URL(request.url).searchParams; + const splat = params["*"] || ""; + throw redirect(`/settings/profile/${splat || "general"}?${searchParams.toString()}`); +}; + +export default function ProfileSettings() { + return null; +} diff --git a/apps/web/app/routes/redirects/core/workspace-account-settings.tsx b/apps/web/app/routes/redirects/core/workspace-account-settings.tsx new file mode 100644 index 0000000000..10d375e9a1 --- /dev/null +++ b/apps/web/app/routes/redirects/core/workspace-account-settings.tsx @@ -0,0 +1,12 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/workspace-account-settings"; + +export const clientLoader = ({ params, request }: Route.ClientLoaderArgs) => { + const searchParams = new URL(request.url).searchParams; + const splat = params["*"] || ""; + throw redirect(`/settings/profile/${splat || "general"}?${searchParams.toString()}`); +}; + +export default function WorkspaceAccountSettings() { + return null; +} diff --git a/apps/web/ce/components/common/modal/global.tsx b/apps/web/ce/components/common/modal/global.tsx new file mode 100644 index 0000000000..76b859c3e8 --- /dev/null +++ b/apps/web/ce/components/common/modal/global.tsx @@ -0,0 +1,26 @@ +import { lazy, Suspense } from "react"; +import { observer } from "mobx-react"; + +const ProfileSettingsModal = lazy(() => + import("@/components/settings/profile/modal").then((module) => ({ + default: module.ProfileSettingsModal, + })) +); + +type TGlobalModalsProps = { + workspaceSlug: string; +}; + +/** + * GlobalModals component manages all workspace-level modals across Plane applications. + * + * This includes: + * - Profile settings modal + */ +export const GlobalModals = observer(function GlobalModals(_props: TGlobalModalsProps) { + return ( + + + + ); +}); diff --git a/apps/web/ce/components/navigations/top-navigation-root.tsx b/apps/web/ce/components/navigations/top-navigation-root.tsx index 9035e3147e..1ee0d9e192 100644 --- a/apps/web/ce/components/navigations/top-navigation-root.tsx +++ b/apps/web/ce/components/navigations/top-navigation-root.tsx @@ -74,7 +74,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
- +
diff --git a/apps/web/ce/components/preferences/config.ts b/apps/web/ce/components/preferences/config.ts deleted file mode 100644 index 1a67ab7d34..0000000000 --- a/apps/web/ce/components/preferences/config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference"; -import { ThemeSwitcher } from "./theme-switcher"; - -export const PREFERENCE_COMPONENTS = { - theme: ThemeSwitcher, - start_of_week: StartOfWeekPreference, -}; diff --git a/apps/web/ce/components/preferences/theme-switcher.tsx b/apps/web/ce/components/preferences/theme-switcher.tsx index d6e6dc252c..b2c2008b1a 100644 --- a/apps/web/ce/components/preferences/theme-switcher.tsx +++ b/apps/web/ce/components/preferences/theme-switcher.tsx @@ -10,8 +10,7 @@ import { applyCustomTheme } from "@plane/utils"; // components import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector"; import { ThemeSwitch } from "@/components/core/theme/theme-switch"; -// helpers -import { PreferencesSection } from "@/components/preferences/section"; +import { SettingsControlItem } from "@/components/settings/control-item"; // hooks import { useUserProfile } from "@/hooks/store/user"; @@ -79,18 +78,16 @@ export const ThemeSwitcher = observer(function ThemeSwitcher(props: { return ( <> - - { - void handleThemeChange(themeOption); - }} - /> -
+ { + void handleThemeChange(themeOption); + }} + /> } /> {userProfile.theme?.theme === "custom" && } diff --git a/apps/web/ce/components/workspace/billing/root.tsx b/apps/web/ce/components/workspace/billing/root.tsx index 2753887d17..ff6b243b9e 100644 --- a/apps/web/ce/components/workspace/billing/root.tsx +++ b/apps/web/ce/components/workspace/billing/root.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "@plane/i18n"; import type { TBillingFrequency, TProductBillingFrequency } from "@plane/types"; import { EProductSubscriptionEnum } from "@plane/types"; // components +import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item"; import { SettingsHeading } from "@/components/settings/heading"; // local imports import { PlansComparison } from "./comparison/root"; @@ -37,32 +38,28 @@ export const BillingRoot = observer(function BillingRoot() { setProductBillingFrequency({ ...productBillingFrequency, [subscriptionType]: frequency }); return ( -
- +
-
-
-
-
-

Community

-
- Unlimited projects, issues, cycles, modules, pages, and storage -
-
-
-
+ +
+
-
All plans
- +
+

All plans

+ +
); }); diff --git a/apps/web/ce/components/workspace/delete-workspace-section.tsx b/apps/web/ce/components/workspace/delete-workspace-section.tsx index 8fb5999291..cc4ab51ecc 100644 --- a/apps/web/ce/components/workspace/delete-workspace-section.tsx +++ b/apps/web/ce/components/workspace/delete-workspace-section.tsx @@ -1,15 +1,14 @@ import { useState } from "react"; import { observer } from "mobx-react"; -// types +// plane imports import { WORKSPACE_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; -import { ChevronDownIcon, ChevronUpIcon } from "@plane/propel/icons"; import type { IWorkspace } from "@plane/types"; -// ui -import { Collapsible } from "@plane/ui"; -import { DeleteWorkspaceModal } from "./delete-workspace-modal"; // components +import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item"; +// local imports +import { DeleteWorkspaceModal } from "./delete-workspace-modal"; type TDeleteWorkspace = { workspace: IWorkspace | null; @@ -18,8 +17,8 @@ type TDeleteWorkspace = { export const DeleteWorkspaceSection = observer(function DeleteWorkspaceSection(props: TDeleteWorkspace) { const { workspace } = props; // states - const [isOpen, setIsOpen] = useState(false); const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState(false); + // translation const { t } = useTranslation(); return ( @@ -29,40 +28,19 @@ export const DeleteWorkspaceSection = observer(function DeleteWorkspaceSection(p isOpen={deleteWorkspaceModal} onClose={() => setDeleteWorkspaceModal(false)} /> -
-
- setIsOpen(!isOpen)} - className="w-full" - buttonClassName="flex w-full items-center justify-between py-4" - title={ - <> - - {t("workspace_settings.settings.general.delete_workspace")} - - {isOpen ? : } - - } + setDeleteWorkspaceModal(true)} + data-ph-element={WORKSPACE_TRACKER_ELEMENTS.DELETE_WORKSPACE_BUTTON} > -
- - {t("workspace_settings.settings.general.delete_workspace_description")} - -
- -
-
-
-
-
+ {t("delete")} + + } + /> ); }); diff --git a/apps/web/ce/constants/project/settings/index.ts b/apps/web/ce/constants/project/settings/index.ts index a6a842e7be..0e849261ac 100644 --- a/apps/web/ce/constants/project/settings/index.ts +++ b/apps/web/ce/constants/project/settings/index.ts @@ -1,2 +1 @@ export * from "./features"; -export * from "./tabs"; diff --git a/apps/web/ce/constants/project/settings/tabs.ts b/apps/web/ce/constants/project/settings/tabs.ts deleted file mode 100644 index f78b51a74f..0000000000 --- a/apps/web/ce/constants/project/settings/tabs.ts +++ /dev/null @@ -1,82 +0,0 @@ -// icons -import { EUserPermissions } from "@plane/constants"; -import { SettingIcon } from "@/components/icons/attachment"; -// types -import type { Props } from "@/components/icons/types"; -// constants - -export const PROJECT_SETTINGS = { - general: { - key: "general", - i18n_label: "common.general", - href: ``, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`, - Icon: SettingIcon, - }, - members: { - key: "members", - i18n_label: "common.members", - href: `/members`, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/members/`, - Icon: SettingIcon, - }, - features: { - key: "features", - i18n_label: "common.features", - href: `/features`, - access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/`, - Icon: SettingIcon, - }, - states: { - key: "states", - i18n_label: "common.states", - href: `/states`, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/states/`, - Icon: SettingIcon, - }, - labels: { - key: "labels", - i18n_label: "common.labels", - href: `/labels`, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/labels/`, - Icon: SettingIcon, - }, - estimates: { - key: "estimates", - i18n_label: "common.estimates", - href: `/estimates`, - access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/estimates/`, - Icon: SettingIcon, - }, - automations: { - key: "automations", - i18n_label: "project_settings.automations.label", - href: `/automations`, - access: [EUserPermissions.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/automations/`, - Icon: SettingIcon, - }, -}; - -export const PROJECT_SETTINGS_LINKS: { - key: string; - i18n_label: string; - href: string; - access: EUserPermissions[]; - highlight: (pathname: string, baseUrl: string) => boolean; - Icon: React.FC; -}[] = [ - PROJECT_SETTINGS["general"], - PROJECT_SETTINGS["members"], - PROJECT_SETTINGS["features"], - PROJECT_SETTINGS["states"], - PROJECT_SETTINGS["labels"], - PROJECT_SETTINGS["estimates"], - PROJECT_SETTINGS["automations"], -]; diff --git a/apps/web/core/components/appearance/index.ts b/apps/web/core/components/appearance/index.ts new file mode 100644 index 0000000000..1d07ba69ea --- /dev/null +++ b/apps/web/core/components/appearance/index.ts @@ -0,0 +1 @@ +export * from "./theme-switcher"; diff --git a/apps/web/core/components/appearance/theme-switcher.tsx b/apps/web/core/components/appearance/theme-switcher.tsx new file mode 100644 index 0000000000..993a06df58 --- /dev/null +++ b/apps/web/core/components/appearance/theme-switcher.tsx @@ -0,0 +1,70 @@ +import { useCallback, useMemo } from "react"; +import { observer } from "mobx-react"; +import { useTheme } from "next-themes"; +// plane imports +import type { I_THEME_OPTION } from "@plane/constants"; +import { THEME_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { setPromiseToast } from "@plane/propel/toast"; +// components +import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector"; +import { ThemeSwitch } from "@/components/core/theme/theme-switch"; +import { SettingsControlItem } from "@/components/settings/control-item"; +// hooks +import { useUserProfile } from "@/hooks/store/user"; + +export const ThemeSwitcher = observer(function ThemeSwitcher(props: { + option: { + id: string; + title: string; + description: string; + }; +}) { + // store hooks + const { data: userProfile, updateUserTheme } = useUserProfile(); + // theme + const { setTheme } = useTheme(); + // translation + const { t } = useTranslation(); + // derived values + const currentTheme = useMemo(() => { + const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme); + return userThemeOption || null; + }, [userProfile?.theme?.theme]); + + const handleThemeChange = useCallback( + (themeOption: I_THEME_OPTION) => { + try { + setTheme(themeOption.value); + const updatePromise = updateUserTheme({ theme: themeOption.value }); + setPromiseToast(updatePromise, { + loading: "Updating theme...", + success: { + title: "Success!", + message: () => "Theme updated successfully!", + }, + error: { + title: "Error!", + message: () => "Failed to update the theme", + }, + }); + } catch (error) { + console.error("Error updating theme:", error); + } + }, + [updateUserTheme] + ); + + if (!userProfile) return null; + + return ( + <> + } + /> + {userProfile.theme?.theme === "custom" && } + + ); +}); diff --git a/apps/web/core/components/automation/auto-archive-automation.tsx b/apps/web/core/components/automation/auto-archive-automation.tsx index 9d3e14afd5..dd01cca409 100644 --- a/apps/web/core/components/automation/auto-archive-automation.tsx +++ b/apps/web/core/components/automation/auto-archive-automation.tsx @@ -2,14 +2,14 @@ import { useMemo, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { ArchiveRestore } from "lucide-react"; -// types +// plane imports import { PROJECT_AUTOMATION_MONTHS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import type { IProject } from "@plane/types"; -// ui import { CustomSelect, Loader, ToggleSwitch } from "@plane/ui"; // component import { SelectMonthModal } from "@/components/automation"; +import { SettingsControlItem } from "@/components/settings/control-item"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; @@ -61,25 +61,22 @@ export const AutoArchiveAutomation = observer(function AutoArchiveAutomation(pro handleClose={() => setmonthModal(false)} handleChange={handleChange} /> -
-
-
-
- -
-
-

{t("project_settings.automations.auto-archive.title")}

-

- {t("project_settings.automations.auto-archive.description")} -

-
+
+
+
+
- + + } + />
- {currentProjectDetails ? ( autoArchiveStatus && ( -
+
{t("project_settings.automations.auto-archive.duration")} @@ -90,9 +87,7 @@ export const AutoArchiveAutomation = observer(function AutoArchiveAutomation(pro label={`${currentProjectDetails?.archive_in} ${ currentProjectDetails?.archive_in === 1 ? "month" : "months" }`} - onChange={(val: number) => { - handleChange({ archive_in: val }); - }} + onChange={(val: number) => void handleChange({ archive_in: val })} input disabled={!isAdmin} > @@ -117,7 +112,7 @@ export const AutoArchiveAutomation = observer(function AutoArchiveAutomation(pro
) ) : ( - + )} diff --git a/apps/web/core/components/automation/auto-close-automation.tsx b/apps/web/core/components/automation/auto-close-automation.tsx index 6b9e7a8d42..7ceaba3b02 100644 --- a/apps/web/core/components/automation/auto-close-automation.tsx +++ b/apps/web/core/components/automation/auto-close-automation.tsx @@ -1,18 +1,15 @@ import { useMemo, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -// icons import { ArchiveX } from "lucide-react"; -// types +// plane imports import { PROJECT_AUTOMATION_MONTHS, EUserPermissions, EUserPermissionsLevel, EIconSize } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { StateGroupIcon, StatePropertyIcon } from "@plane/propel/icons"; import type { IProject } from "@plane/types"; -// ui import { CustomSelect, CustomSearchSelect, ToggleSwitch, Loader } from "@plane/ui"; -// component import { SelectMonthModal } from "@/components/automation"; -// constants +import { SettingsControlItem } from "@/components/settings/control-item"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useProjectState } from "@/hooks/store/use-project-state"; @@ -82,36 +79,34 @@ export const AutoCloseAutomation = observer(function AutoCloseAutomation(props: handleClose={() => setmonthModal(false)} handleChange={handleChange} /> -
-
-
-
- -
-
-

{t("project_settings.automations.auto-close.title")}

-

- {t("project_settings.automations.auto-close.description")} -

-
+
+
+
+
- { - if (currentProjectDetails?.close_in === 0) { - await handleChange({ close_in: 1, default_state: defaultState }); - } else { - await handleChange({ close_in: 0, default_state: null }); - } - }} - size="sm" - disabled={!isAdmin} + { + if (currentProjectDetails?.close_in === 0) { + void handleChange({ close_in: 1, default_state: defaultState }); + } else { + void handleChange({ close_in: 0, default_state: null }); + } + }} + size="sm" + disabled={!isAdmin} + /> + } />
{currentProjectDetails ? ( autoCloseStatus && ( -
+
@@ -123,9 +118,7 @@ export const AutoCloseAutomation = observer(function AutoCloseAutomation(props: label={`${currentProjectDetails?.close_in} ${ currentProjectDetails?.close_in === 1 ? "month" : "months" }`} - onChange={(val: number) => { - handleChange({ close_in: val }); - }} + onChange={(val: number) => void handleChange({ close_in: val })} input disabled={!isAdmin} > @@ -176,9 +169,7 @@ export const AutoCloseAutomation = observer(function AutoCloseAutomation(props: : (currentDefaultState?.name ?? {t("state")})}
} - onChange={(val: string) => { - handleChange({ default_state: val }); - }} + onChange={(val: string) => void handleChange({ default_state: val })} options={options} disabled={!multipleOptions} input @@ -189,7 +180,7 @@ export const AutoCloseAutomation = observer(function AutoCloseAutomation(props:
) ) : ( - + )} diff --git a/apps/web/core/components/core/theme/color-inputs.tsx b/apps/web/core/components/core/theme/color-inputs.tsx new file mode 100644 index 0000000000..3459a203a0 --- /dev/null +++ b/apps/web/core/components/core/theme/color-inputs.tsx @@ -0,0 +1,92 @@ +import { observer } from "mobx-react"; +import type { Control } from "react-hook-form"; +import { Controller } from "react-hook-form"; +// plane imports +import type { IUserTheme } from "@plane/types"; +import { InputColorPicker } from "@plane/ui"; + +type Props = { + control: Control; +}; + +export const CustomThemeColorInputs = observer(function CustomThemeColorInputs(props: Props) { + const { control } = props; + + const handleValueChange = (val: string | undefined, onChange: (...args: unknown[]) => void) => { + let hex = val; + // prepend a hashtag if it doesn't exist + if (val && val[0] !== "#") hex = `#${val}`; + onChange(hex); + }; + + return ( +
+ {/* Neutral Color */} +
+

+ Neutral color* +

+
+ ( + handleValueChange(val, onChange)} + placeholder="#1a1a1a" + className="w-full placeholder:text-placeholder" + style={{ + backgroundColor: value, + color: "#ffffff", + }} + hasError={false} + /> + )} + /> +
+
+ {/* Brand Color */} +
+

+ Brand color* +

+
+ ( + handleValueChange(val, onChange)} + placeholder="#3f76ff" + className="w-full placeholder:text-placeholder" + style={{ + backgroundColor: value, + color: "#ffffff", + }} + hasError={false} + /> + )} + /> +
+
+
+ ); +}); diff --git a/apps/web/core/components/core/theme/custom-theme-selector.tsx b/apps/web/core/components/core/theme/custom-theme-selector.tsx index 1091966e1c..e498840b69 100644 --- a/apps/web/core/components/core/theme/custom-theme-selector.tsx +++ b/apps/web/core/components/core/theme/custom-theme-selector.tsx @@ -1,17 +1,21 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { observer } from "mobx-react"; -import { Controller, useForm } from "react-hook-form"; +import { useForm } from "react-hook-form"; // plane imports import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IUserTheme } from "@plane/types"; -import { InputColorPicker, ToggleSwitch } from "@plane/ui"; import { applyCustomTheme } from "@plane/utils"; +// components +import { ProfileSettingsHeading } from "@/components/settings/profile/heading"; // hooks import { useUserProfile } from "@/hooks/store/user"; // local imports -import { CustomThemeConfigHandler } from "./config-handler"; +import { CustomThemeColorInputs } from "./color-inputs"; +import { CustomThemeDownloadConfigButton } from "./download-config-button"; +import { CustomThemeImportConfigButton } from "./import-config-button"; +import { CustomThemeModeSelector } from "./theme-mode-selector"; export const CustomThemeSelector = observer(function CustomThemeSelector() { // store hooks @@ -23,18 +27,17 @@ export const CustomThemeSelector = observer(function CustomThemeSelector() { const [isLoadingPalette, setIsLoadingPalette] = useState(false); // Load saved theme from userProfile (fallback to defaults) - const getSavedTheme = (): IUserTheme => { - if (userProfile?.theme) { - const theme = userProfile.theme; - if (theme.primary && theme.background && theme.darkPalette !== undefined) { - return { - theme: "custom", - primary: theme.primary, - background: theme.background, - darkPalette: theme.darkPalette, - }; - } + const savedTheme = useMemo((): IUserTheme => { + const theme = userProfile?.theme; + if (theme && theme.primary && theme.background) { + return { + theme: "custom", + primary: theme.primary, + background: theme.background, + darkPalette: !!theme.darkPalette, + }; } + // Fallback to defaults return { theme: "custom", @@ -42,21 +45,20 @@ export const CustomThemeSelector = observer(function CustomThemeSelector() { background: "#1a1a1a", darkPalette: false, }; - }; + }, [userProfile?.theme]); const { control, formState: { isSubmitting }, handleSubmit, getValues, - watch, setValue, } = useForm({ - defaultValues: getSavedTheme(), + defaultValues: savedTheme, }); const handleUpdateTheme = async (formData: IUserTheme) => { - if (!formData.primary || !formData.background || formData.darkPalette === undefined) return; + if (!formData.primary || !formData.background) return; try { setIsLoadingPalette(true); @@ -90,109 +92,29 @@ export const CustomThemeSelector = observer(function CustomThemeSelector() { } }; - const handleValueChange = (val: string | undefined, onChange: (...args: unknown[]) => void) => { - let hex = val; - // prepend a hashtag if it doesn't exist - if (val && val[0] !== "#") hex = `#${val}`; - onChange(hex); - }; - return ( -
+ { + void handleSubmit(handleUpdateTheme)(e); + }} + className="bg-layer-1 border border-subtle rounded-lg py-3 px-4" + >
-

{t("customize_your_theme")}

- -
- {/* Color Inputs */} -
- {/* Brand Color */} -
-

Brand color

-
- ( - handleValueChange(val, onChange)} - placeholder="#3f76ff" - className="w-full placeholder:text-placeholder" - style={{ - backgroundColor: value, - color: "#ffffff", - }} - hasError={false} - /> - )} - /> -
-
- - {/* Neutral Color */} -
-

Neutral color

-
- ( - handleValueChange(val, onChange)} - placeholder="#1a1a1a" - className="w-full placeholder:text-placeholder" - style={{ - backgroundColor: value, - color: "#ffffff", - }} - hasError={false} - /> - )} - /> -
-
-
-
+ } + /> + + {/* Color Inputs */} +
-
+ {/* Save Theme Button */} + {/* Import/Export Section */} - - -
- {/* Theme Mode Toggle */} -
- ( - - )} - /> - {watch("darkPalette") ? "Dark mode" : "Light mode"} -
- {/* Save Theme Button */} - -
+
); diff --git a/apps/web/core/components/core/theme/download-config-button.tsx b/apps/web/core/components/core/theme/download-config-button.tsx new file mode 100644 index 0000000000..0a80a2aa2a --- /dev/null +++ b/apps/web/core/components/core/theme/download-config-button.tsx @@ -0,0 +1,59 @@ +import { observer } from "mobx-react"; +import type { UseFormGetValues } from "react-hook-form"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { setToast, TOAST_TYPE } from "@plane/propel/toast"; +import type { IUserTheme } from "@plane/types"; + +type Props = { + getValues: UseFormGetValues; +}; + +export const CustomThemeDownloadConfigButton = observer(function CustomThemeDownloadConfigButton(props: Props) { + const { getValues } = props; + // translation + const { t } = useTranslation(); + + const handleDownloadConfig = () => { + try { + const currentValues = getValues(); + const config = { + version: "1.0", + themeName: "Custom Theme", + primary: currentValues.primary, + background: currentValues.background, + darkPalette: currentValues.darkPalette, + }; + + const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `plane-theme-${Date.now()}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("success"), + message: "Theme configuration downloaded successfully.", + }); + } catch (error) { + console.error("Failed to download config:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: "Failed to download theme configuration.", + }); + } + }; + + return ( + + ); +}); diff --git a/apps/web/core/components/core/theme/config-handler.tsx b/apps/web/core/components/core/theme/import-config-button.tsx similarity index 62% rename from apps/web/core/components/core/theme/config-handler.tsx rename to apps/web/core/components/core/theme/import-config-button.tsx index a426c75079..c585e3c507 100644 --- a/apps/web/core/components/core/theme/config-handler.tsx +++ b/apps/web/core/components/core/theme/import-config-button.tsx @@ -1,6 +1,6 @@ import { useRef } from "react"; import { observer } from "mobx-react"; -import type { UseFormGetValues, UseFormSetValue } from "react-hook-form"; +import type { UseFormSetValue } from "react-hook-form"; // plane imports import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; @@ -8,54 +8,17 @@ import { setToast, TOAST_TYPE } from "@plane/propel/toast"; import type { IUserTheme } from "@plane/types"; type Props = { - getValues: UseFormGetValues; handleUpdateTheme: (formData: IUserTheme) => Promise; setValue: UseFormSetValue; }; -export const CustomThemeConfigHandler = observer(function CustomThemeConfigHandler(props: Props) { - const { getValues, handleUpdateTheme, setValue } = props; +export const CustomThemeImportConfigButton = observer(function CustomThemeImportConfigButton(props: Props) { + const { handleUpdateTheme, setValue } = props; // refs const fileInputRef = useRef(null); // translation const { t } = useTranslation(); - const handleDownloadConfig = () => { - try { - const currentValues = getValues(); - const config = { - version: "1.0", - themeName: "Custom Theme", - primary: currentValues.primary, - background: currentValues.background, - darkPalette: currentValues.darkPalette, - }; - - const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `plane-theme-${Date.now()}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - setToast({ - type: TOAST_TYPE.SUCCESS, - title: t("success"), - message: "Theme configuration downloaded successfully.", - }); - } catch (error) { - console.error("Failed to download config:", error); - setToast({ - type: TOAST_TYPE.ERROR, - title: t("error"), - message: "Failed to download theme configuration.", - }); - } - }; - const handleUploadConfig = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; @@ -122,14 +85,11 @@ export const CustomThemeConfigHandler = observer(function CustomThemeConfigHandl }; return ( -
+ <> - - -
+ ); }); diff --git a/apps/web/core/components/core/theme/theme-mode-selector.tsx b/apps/web/core/components/core/theme/theme-mode-selector.tsx new file mode 100644 index 0000000000..d791a14864 --- /dev/null +++ b/apps/web/core/components/core/theme/theme-mode-selector.tsx @@ -0,0 +1,51 @@ +import { observer } from "mobx-react"; +import type { Control } from "react-hook-form"; +import { Controller } from "react-hook-form"; +// plane imports +import type { IUserTheme } from "@plane/types"; + +type Props = { + control: Control; +}; + +export const CustomThemeModeSelector = observer(function CustomThemeModeSelector(props: Props) { + const { control } = props; + + return ( +
+
+ Choose color mode* +
+ ( +
+ + +
+ )} + /> +
+ ); +}); diff --git a/apps/web/core/components/core/theme/theme-switch.tsx b/apps/web/core/components/core/theme/theme-switch.tsx index a6cd5cb41f..d0a0ac3b6d 100644 --- a/apps/web/core/components/core/theme/theme-switch.tsx +++ b/apps/web/core/components/core/theme/theme-switch.tsx @@ -50,6 +50,7 @@ export function ThemeSwitch(props: Props) { ) } onChange={onChange} + buttonClassName="border border-subtle-1" placement="bottom-end" input > diff --git a/apps/web/core/components/estimates/estimate-list-item.tsx b/apps/web/core/components/estimates/estimate-list-item.tsx index b8f951192b..0ae6888866 100644 --- a/apps/web/core/components/estimates/estimate-list-item.tsx +++ b/apps/web/core/components/estimates/estimate-list-item.tsx @@ -1,12 +1,13 @@ -import type { FC } from "react"; import { observer } from "mobx-react"; +// plane imports import { EEstimateSystem } from "@plane/constants"; -import { convertMinutesToHoursMinutesString, cn } from "@plane/utils"; -// helpers +import { convertMinutesToHoursMinutesString } from "@plane/utils"; +// components +import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item"; // hooks import { useProjectEstimates } from "@/hooks/store/estimates"; import { useEstimate } from "@/hooks/store/estimates/use-estimate"; -// plane web components +// plane web imports import { EstimateListItemButtons } from "@/plane-web/components/estimates"; type TEstimateListItem = { @@ -19,40 +20,31 @@ type TEstimateListItem = { }; export const EstimateListItem = observer(function EstimateListItem(props: TEstimateListItem) { - const { estimateId, isAdmin, isEstimateEnabled, isEditable } = props; - // hooks + const { estimateId } = props; + // store hooks const { estimateById } = useProjectEstimates(); const { estimatePointIds, estimatePointById } = useEstimate(estimateId); const currentEstimate = estimateById(estimateId); - // derived values const estimatePointValues = estimatePointIds?.map((estimatePointId) => { const estimatePoint = estimatePointById(estimatePointId); if (estimatePoint) return estimatePoint.value; }); - if (!currentEstimate) return <>; + if (!currentEstimate) return null; + return ( -
-
-

{currentEstimate?.name}

-

- {estimatePointValues - ?.map((estimatePointValue) => { - if (currentEstimate?.type === EEstimateSystem.TIME) { - return convertMinutesToHoursMinutesString(Number(estimatePointValue)); - } - return estimatePointValue; - }) - .join(", ")} -

-
- -
+ { + if (currentEstimate.type === EEstimateSystem.TIME) { + return convertMinutesToHoursMinutesString(Number(estimatePointValue)); + } + return estimatePointValue; + }) + .join(", ")} + control={} + /> ); }); diff --git a/apps/web/core/components/estimates/root.tsx b/apps/web/core/components/estimates/root.tsx index 3a2310b581..53b7e05e41 100644 --- a/apps/web/core/components/estimates/root.tsx +++ b/apps/web/core/components/estimates/root.tsx @@ -1,9 +1,11 @@ -import type { FC } from "react"; import { useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; // plane imports import { useTranslation } from "@plane/i18n"; +// components +import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item"; +import { SettingsHeading } from "@/components/settings/heading"; // hooks import { EmptyStateCompact } from "@plane/propel/empty-state"; import { useProjectEstimates } from "@/hooks/store/estimates"; @@ -11,7 +13,6 @@ import { useProject } from "@/hooks/store/use-project"; // plane web components import { UpdateEstimateModal } from "@/plane-web/components/estimates"; // local imports -import { SettingsHeading } from "../settings/heading"; import { CreateEstimateModal } from "./create/modal"; import { DeleteEstimateModal } from "./delete/modal"; import { EstimateDisableSwitch } from "./estimate-disable-switch"; @@ -41,40 +42,43 @@ export const EstimateRoot = observer(function EstimateRoot(props: TEstimateRoot) async () => workspaceSlug && projectId && getProjectEstimates(workspaceSlug, projectId) ); + if (loader === "init-loader" || isSWRLoading) { + return ; + } + return ( -
- {loader === "init-loader" || isSWRLoading ? ( - - ) : ( -
- {/* header */} - - - + <> +
+ {/* header */} + +
{/* current active estimate section */} {currentActiveEstimateId ? ( -
+ <> {/* estimates activated deactivated section */} -
-
-

{t("project_settings.estimates.title")}

-

{t("project_settings.estimates.enable_description")}

-
- -
- {/* active estimates section */} - setEstimateToUpdate(estimateId)} - onDeleteClick={(estimateId: string) => setEstimateToDelete(estimateId)} + + } /> -
+ {/* active estimates section */} +
+ + setEstimateToUpdate(estimateId)} + onDeleteClick={(estimateId: string) => setEstimateToDelete(estimateId)} + /> +
+ ) : ( )} - {/* archived estimates section */} {archivedEstimateIds && archivedEstimateIds.length > 0 && ( -
-
-

Archived estimates

-

- Estimates have gone through a change, these are the estimates you had in your older versions which - were not in use. Read more about them  - - here. - -

-
+
+ + Estimates have gone through a change, these are the estimates you had in your older versions which + were not in use. Read more about them  + + here. + + + } + variant="h6" + />
)}
- )} - +
{/* CRUD modals */} setEstimateToDelete(undefined)} /> -
+ ); }); diff --git a/apps/web/core/components/exporter/export-form.tsx b/apps/web/core/components/exporter/export-form.tsx index c56e462b50..d71014bb24 100644 --- a/apps/web/core/components/exporter/export-form.tsx +++ b/apps/web/core/components/exporter/export-form.tsx @@ -21,6 +21,8 @@ import { CustomSearchSelect, CustomSelect } from "@plane/ui"; import { useProject } from "@/hooks/store/use-project"; import { useUser, useUserPermissions } from "@/hooks/store/user"; import { ProjectExportService } from "@/services/project/project-export.service"; +// local imports +import { SettingsBoxedControlItem } from "../settings/boxed-control-item"; type Props = { workspaceSlug: string; @@ -134,68 +136,75 @@ export const ExportForm = observer(function ExportForm(props: Props) { onSubmit={(e) => { void handleSubmit(ExportCSVToMail)(e); }} - className="flex flex-col gap-4 mt-4" + className="flex flex-col gap-5" > -
+
{/* Project Selector */} -
-
- {t("workspace_settings.settings.exports.exporting_projects")} -
- ( - onChange(val)} - options={options} - input - label={ - value && value.length > 0 - ? value - .map((projectId) => { - const projectDetails = getProjectById(projectId); + ( + onChange(val)} + options={options} + input + label={ + value && value.length > 0 + ? value + .map((projectId) => { + const projectDetails = getProjectById(projectId); - return projectDetails?.identifier; - }) - .join(", ") - : "All projects" - } - optionsClassName="max-w-48 sm:max-w-[532px]" - placement="bottom-end" - multiple - /> - )} - /> -
+ return projectDetails?.identifier; + }) + .join(", ") + : "All projects" + } + optionsClassName="max-w-48 sm:max-w-[532px]" + placement="bottom-end" + multiple + /> + )} + /> + } + /> {/* Format Selector */} -
-
- {t("workspace_settings.settings.exports.format")} -
- ( - - {EXPORTERS_LIST.map((service) => ( - - {t(service.i18n_title)} - - ))} - - )} - /> + ( + + {EXPORTERS_LIST.map((service) => ( + + {t(service.i18n_title)} + + ))} + + )} + /> + } + /> +
+
{/* Rich Filters */} @@ -241,11 +250,6 @@ export const ExportForm = observer(function ExportForm(props: Props) { )} />
*/} -
- -
); }); diff --git a/apps/web/core/components/exporter/guide.tsx b/apps/web/core/components/exporter/guide.tsx index b35a9125f0..7861f0d9b0 100644 --- a/apps/web/core/components/exporter/guide.tsx +++ b/apps/web/core/components/exporter/guide.tsx @@ -2,11 +2,13 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { useParams, useSearchParams } from "next/navigation"; import { mutate } from "swr"; +// constants import { EXPORT_SERVICES_LIST } from "@/constants/fetch-keys"; +// local imports import { ExportForm } from "./export-form"; import { PrevExports } from "./prev-exports"; -const IntegrationGuide = observer(function IntegrationGuide() { +export const ExportGuide = observer(function ExportGuide() { // router const { workspaceSlug } = useParams(); const searchParams = useSearchParams(); @@ -17,18 +19,14 @@ const IntegrationGuide = observer(function IntegrationGuide() { return ( <> -
- <> - mutate(EXPORT_SERVICES_LIST(workspaceSlug, `${cursor}`, `${per_page}`))} - /> - - +
+ mutate(EXPORT_SERVICES_LIST(workspaceSlug, `${cursor}`, `${per_page}`))} + /> +
); }); - -export default IntegrationGuide; diff --git a/apps/web/core/components/exporter/prev-exports.tsx b/apps/web/core/components/exporter/prev-exports.tsx index dbb8dbb73a..d777ef8a45 100644 --- a/apps/web/core/components/exporter/prev-exports.tsx +++ b/apps/web/core/components/exporter/prev-exports.tsx @@ -59,11 +59,9 @@ export const PrevExports = observer(function PrevExports(props: Props) { return (
-
+
-

- {t("workspace_settings.settings.exports.previous_exports")} -

+

{t("workspace_settings.settings.exports.previous_exports")}

)}
-
{exporterServices && exporterServices?.results ? ( exporterServices?.results?.length > 0 ? ( diff --git a/apps/web/core/components/global/timezone-select.tsx b/apps/web/core/components/global/timezone-select.tsx index 59f3285e75..1a25a022a6 100644 --- a/apps/web/core/components/global/timezone-select.tsx +++ b/apps/web/core/components/global/timezone-select.tsx @@ -1,5 +1,5 @@ -import type { FC } from "react"; import { observer } from "mobx-react"; +// plane imports import { CustomSearchSelect } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks @@ -38,13 +38,14 @@ export const TimezoneSelect = observer(function TimezoneSelect(props: TTimezoneS label={value && selectedValue ? selectedValue(value) : label} options={isDisabled || disabled ? [] : timezones} onChange={onChange} - buttonClassName={cn(buttonClassName, { + buttonClassName={cn(buttonClassName, "border border-subtle-1", { "border-danger-strong": error, })} - className={cn("rounded-md border-[0.5px] !border-subtle", className)} + className={cn("rounded-md", className)} optionsClassName={cn("w-72", optionsClassName)} input disabled={isDisabled || disabled} + placement="bottom-end" />
); diff --git a/apps/web/core/components/home/widgets/empty-states/no-projects.tsx b/apps/web/core/components/home/widgets/empty-states/no-projects.tsx index fe4384cb7e..09cb48f461 100644 --- a/apps/web/core/components/home/widgets/empty-states/no-projects.tsx +++ b/apps/web/core/components/home/widgets/empty-states/no-projects.tsx @@ -108,7 +108,7 @@ export const NoProjectsEmptyState = observer(function NoProjectsEmptyState() { flag: "visited_profile", cta: { text: "home.empty.personalize_account.cta", - link: `/${workspaceSlug}/settings/account`, + link: `/settings/profile/general`, disabled: false, }, }, diff --git a/apps/web/core/components/integration/delete-import-modal.tsx b/apps/web/core/components/integration/delete-import-modal.tsx deleted file mode 100644 index c09b0feac8..0000000000 --- a/apps/web/core/components/integration/delete-import-modal.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useState } from "react"; -import { useParams } from "next/navigation"; -import { mutate } from "swr"; -// icons -import { AlertTriangle } from "lucide-react"; -// services -import { Button } from "@plane/propel/button"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import type { IUser, IImporterService } from "@plane/types"; -import { Input, EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; -import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys"; -import { IntegrationService } from "@/services/integrations/integration.service"; - -type Props = { - isOpen: boolean; - handleClose: () => void; - data: IImporterService | null; - user: IUser | null; -}; - -// services -const integrationService = new IntegrationService(); - -export function DeleteImportModal({ isOpen, handleClose, data }: Props) { - const [deleteLoading, setDeleteLoading] = useState(false); - const [confirmDeleteImport, setConfirmDeleteImport] = useState(false); - - const { workspaceSlug } = useParams(); - - const handleDeletion = () => { - if (!workspaceSlug || !data) return; - - setDeleteLoading(true); - - mutate( - IMPORTER_SERVICES_LIST(workspaceSlug), - (prevData) => (prevData ?? []).filter((i) => i.id !== data.id), - false - ); - - integrationService - .deleteImporterService(workspaceSlug, data.service, data.id) - .catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Something went wrong. Please try again.", - }) - ) - .finally(() => { - setDeleteLoading(false); - handleClose(); - }); - }; - - if (!data) return <>; - - return ( - -
-
- - - -

Delete project

-
-
- -

- Are you sure you want to delete import from{" "} - {data?.service}? All of the data - related to the import will be permanently removed. This action cannot be undone. -

-
-
-

- To confirm, type delete import below: -

- { - if (e.target.value === "delete import") setConfirmDeleteImport(true); - else setConfirmDeleteImport(false); - }} - placeholder="Enter 'delete import'" - className="mt-2 w-full" - /> -
-
- - -
-
-
- ); -} diff --git a/apps/web/core/components/integration/github/auth.tsx b/apps/web/core/components/integration/github/auth.tsx deleted file mode 100644 index 60ed2e35fa..0000000000 --- a/apps/web/core/components/integration/github/auth.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { observer } from "mobx-react"; -// types -import { Button } from "@plane/propel/button"; -import type { IWorkspaceIntegration } from "@plane/types"; -// ui -// hooks -import { useInstance } from "@/hooks/store/use-instance"; -import useIntegrationPopup from "@/hooks/use-integration-popup"; - -type Props = { - workspaceIntegration: false | IWorkspaceIntegration | undefined; - provider: string | undefined; -}; - -export const GithubAuth = observer(function GithubAuth({ workspaceIntegration, provider }: Props) { - // store hooks - const { config } = useInstance(); - // hooks - const { startAuth, isConnecting } = useIntegrationPopup({ - provider, - github_app_name: config?.github_app_name || "", - slack_client_id: config?.slack_client_id || "", - }); - - return ( -
- {workspaceIntegration && workspaceIntegration?.id ? ( - - ) : ( - - )} -
- ); -}); diff --git a/apps/web/core/components/integration/github/import-configure.tsx b/apps/web/core/components/integration/github/import-configure.tsx deleted file mode 100644 index edcc316c6e..0000000000 --- a/apps/web/core/components/integration/github/import-configure.tsx +++ /dev/null @@ -1,50 +0,0 @@ -// components -import { Button } from "@plane/propel/button"; -import type { IAppIntegration, IWorkspaceIntegration } from "@plane/types"; -import type { TIntegrationSteps } from "@/components/integration"; -import { GithubAuth } from "@/components/integration"; -// types - -type Props = { - provider: string | undefined; - handleStepChange: (value: TIntegrationSteps) => void; - appIntegrations: IAppIntegration[] | undefined; - workspaceIntegrations: IWorkspaceIntegration[] | undefined; -}; - -export function GithubImportConfigure({ handleStepChange, provider, appIntegrations, workspaceIntegrations }: Props) { - // current integration from all the integrations available - const integration = - appIntegrations && appIntegrations.length > 0 && appIntegrations.find((i) => i.provider === provider); - - // current integration from workspace integrations - const workspaceIntegration = - integration && - workspaceIntegrations && - workspaceIntegrations.length > 0 && - workspaceIntegrations.find((i: any) => i.integration_detail.id === integration.id); - - return ( -
-
-
-
Configure
-
Set up your GitHub import.
-
-
- -
-
- -
- -
-
- ); -} diff --git a/apps/web/core/components/integration/github/import-confirm.tsx b/apps/web/core/components/integration/github/import-confirm.tsx deleted file mode 100644 index ba15e1a668..0000000000 --- a/apps/web/core/components/integration/github/import-confirm.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { FC } from "react"; - -// react-hook-form -import type { UseFormWatch } from "react-hook-form"; -// ui -import { Button } from "@plane/propel/button"; -// types -import type { TFormValues, TIntegrationSteps } from "@/components/integration"; - -type Props = { - handleStepChange: (value: TIntegrationSteps) => void; - watch: UseFormWatch; -}; - -export function GithubImportConfirm({ handleStepChange, watch }: Props) { - return ( -
-

- You are about to import work items from {watch("github").full_name}. Click on {'"'}Confirm & Import{'" '} - to complete the process. -

-
- - -
-
- ); -} diff --git a/apps/web/core/components/integration/github/import-data.tsx b/apps/web/core/components/integration/github/import-data.tsx deleted file mode 100644 index 83926e2d7f..0000000000 --- a/apps/web/core/components/integration/github/import-data.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import type { FC } from "react"; -import { observer } from "mobx-react"; -import type { Control, UseFormWatch } from "react-hook-form"; -import { Controller } from "react-hook-form"; -import { Button } from "@plane/propel/button"; -import type { IWorkspaceIntegration } from "@plane/types"; -// hooks -// components -import { CustomSearchSelect, ToggleSwitch } from "@plane/ui"; -import { truncateText } from "@plane/utils"; -import type { TFormValues, TIntegrationSteps } from "@/components/integration"; -import { SelectRepository } from "@/components/integration"; -// ui -// helpers -import { useProject } from "@/hooks/store/use-project"; -// types - -type Props = { - handleStepChange: (value: TIntegrationSteps) => void; - integration: IWorkspaceIntegration | false | undefined; - control: Control; - watch: UseFormWatch; -}; - -export const GithubImportData = observer(function GithubImportData(props: Props) { - const { handleStepChange, integration, control, watch } = props; - // store hooks - const { workspaceProjectIds, getProjectById } = useProject(); - - const options = workspaceProjectIds?.map((projectId) => { - const projectDetails = getProjectById(projectId); - - return { - value: `${projectDetails?.id}`, - query: `${projectDetails?.name}`, - content:

{truncateText(projectDetails?.name ?? "", 25)}

, - }; - }); - - return ( -
-
-
-
-

Select Repository

-

- Select the repository that you want the work items to be imported from. -

-
-
- {integration && ( - ( - Select Repository} - onChange={onChange} - characterLimit={50} - /> - )} - /> - )} -
-
-
-
-

Select Project

-

Select the project to import the work item to.

-
-
- {workspaceProjectIds && ( - ( - Select Project} - onChange={onChange} - options={options} - optionsClassName="w-48" - /> - )} - /> - )} -
-
-
-
-

Sync work item

-

Set whether you want to sync the work items or not.

-
-
- ( - onChange(!value)} /> - )} - /> -
-
-
-
- - -
-
- ); -}); diff --git a/apps/web/core/components/integration/github/import-users.tsx b/apps/web/core/components/integration/github/import-users.tsx deleted file mode 100644 index 0e53aa3d75..0000000000 --- a/apps/web/core/components/integration/github/import-users.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { FC } from "react"; - -// react-hook-form -import type { UseFormWatch } from "react-hook-form"; -// ui -import { Button } from "@plane/propel/button"; -// types -import type { IUserDetails, TFormValues, TIntegrationSteps } from "@/components/integration"; -import { SingleUserSelect } from "@/components/integration"; - -type Props = { - handleStepChange: (value: TIntegrationSteps) => void; - users: IUserDetails[]; - setUsers: React.Dispatch>; - watch: UseFormWatch; -}; - -export function GithubImportUsers({ handleStepChange, users, setUsers, watch }: Props) { - const isInvalid = users.filter((u) => u.import !== false && u.email === "").length > 0; - - return ( -
-
-
-
Name
-
Import as...
-
{users.filter((u) => u.import !== false).length} users selected
-
-
- {watch("collaborators").map((collaborator, index) => ( - - ))} -
-
-
- - -
-
- ); -} diff --git a/apps/web/core/components/integration/github/index.ts b/apps/web/core/components/integration/github/index.ts deleted file mode 100644 index c215e9a0c5..0000000000 --- a/apps/web/core/components/integration/github/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from "./auth"; -export * from "./import-configure"; -export * from "./import-confirm"; -export * from "./import-data"; -export * from "./import-users"; -export * from "./repo-details"; -export * from "./root"; -export * from "./select-repository"; -export * from "./single-user-select"; diff --git a/apps/web/core/components/integration/github/repo-details.tsx b/apps/web/core/components/integration/github/repo-details.tsx deleted file mode 100644 index a04474075c..0000000000 --- a/apps/web/core/components/integration/github/repo-details.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import type { FC } from "react"; -import { useEffect } from "react"; - -import { useParams } from "next/navigation"; - -// react-hook-form -import type { UseFormSetValue } from "react-hook-form"; -import useSWR from "swr"; -// services -// ui -import { Button } from "@plane/propel/button"; -import { Loader } from "@plane/ui"; -// types -import type { IUserDetails, TFormValues, TIntegrationSteps } from "@/components/integration"; -// fetch-keys -import { GITHUB_REPOSITORY_INFO } from "@/constants/fetch-keys"; -import { GithubIntegrationService } from "@/services/integrations"; - -type Props = { - selectedRepo: any; - handleStepChange: (value: TIntegrationSteps) => void; - setUsers: React.Dispatch>; - setValue: UseFormSetValue; -}; - -// services -const githubIntegrationService = new GithubIntegrationService(); - -export function GithubRepoDetails({ selectedRepo, handleStepChange, setUsers, setValue }: Props) { - const { workspaceSlug } = useParams(); - - const { data: repoInfo } = useSWR( - workspaceSlug && selectedRepo ? GITHUB_REPOSITORY_INFO(workspaceSlug, selectedRepo.name) : null, - workspaceSlug && selectedRepo - ? () => - githubIntegrationService.getGithubRepoInfo(workspaceSlug, { - owner: selectedRepo.owner.login, - repo: selectedRepo.name, - }) - : null - ); - - useEffect(() => { - if (!repoInfo) return; - - setValue("collaborators", repoInfo.collaborators); - - const fetchedUsers = repoInfo.collaborators.map((collaborator) => ({ - username: collaborator.login, - import: "map", - email: "", - })); - setUsers(fetchedUsers); - }, [repoInfo, setUsers, setValue]); - - return ( -
- {repoInfo ? ( - repoInfo.issue_count > 0 ? ( -
-
-
Repository Details
-
Import completed. We have found:
-
-
-
-

{repoInfo.issue_count}

-
Work items
-
-
-

{repoInfo.labels}

-
Labels
-
-
-

{repoInfo.collaborators.length}

-
Users
-
-
-
- ) : ( -
-
We didn{"'"}t find any work item in this repository.
-
- ) - ) : ( - - - - )} -
- - -
-
- ); -} diff --git a/apps/web/core/components/integration/github/root.tsx b/apps/web/core/components/integration/github/root.tsx deleted file mode 100644 index 4e144fe45d..0000000000 --- a/apps/web/core/components/integration/github/root.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import React, { useState } from "react"; -import Link from "next/link"; -import { useParams, useSearchParams } from "next/navigation"; -import { useForm } from "react-hook-form"; -import useSWR, { mutate } from "swr"; -import { ArrowLeft, List, Settings, UploadCloud } from "lucide-react"; -import { CheckIcon, MembersPropertyIcon } from "@plane/propel/icons"; -// types -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import type { IGithubRepoCollaborator, IGithubServiceImportFormData } from "@plane/types"; -// assets -import GithubLogo from "@/app/assets/services/github.png?url"; -// components -import { - GithubImportConfigure, - GithubImportData, - GithubRepoDetails, - GithubImportUsers, - GithubImportConfirm, -} from "@/components/integration"; -// fetch keys -import { APP_INTEGRATIONS, IMPORTER_SERVICES_LIST, WORKSPACE_INTEGRATIONS } from "@/constants/fetch-keys"; -// hooks -import { useAppRouter } from "@/hooks/use-app-router"; -// services -import { IntegrationService, GithubIntegrationService } from "@/services/integrations"; - -export type TIntegrationSteps = "import-configure" | "import-data" | "repo-details" | "import-users" | "import-confirm"; -export interface IIntegrationData { - state: TIntegrationSteps; -} - -export interface IUserDetails { - username: string; - import: any; - email: string; -} - -export type TFormValues = { - github: any; - project: string | null; - sync: boolean; - collaborators: IGithubRepoCollaborator[]; - users: IUserDetails[]; -}; - -const defaultFormValues = { - github: null, - project: null, - sync: false, -}; - -const integrationWorkflowData = [ - { - title: "Configure", - key: "import-configure", - icon: Settings, - }, - { - title: "Import Data", - key: "import-data", - icon: UploadCloud, - }, - { title: "Work item", key: "repo-details", icon: List }, - { - title: "Users", - key: "import-users", - icon: MembersPropertyIcon, - }, - { - title: "Confirm", - key: "import-confirm", - icon: CheckIcon, - }, -]; - -// services -const integrationService = new IntegrationService(); -const githubIntegrationService = new GithubIntegrationService(); - -export function GithubImporterRoot() { - const [currentStep, setCurrentStep] = useState({ - state: "import-configure", - }); - const [users, setUsers] = useState([]); - - const router = useAppRouter(); - const { workspaceSlug } = useParams(); - const searchParams = useSearchParams(); - const provider = searchParams.get("provider"); - - const { handleSubmit, control, setValue, watch } = useForm({ - defaultValues: defaultFormValues, - }); - - const { data: appIntegrations } = useSWR(APP_INTEGRATIONS, () => integrationService.getAppIntegrationsList()); - - const { data: workspaceIntegrations } = useSWR( - workspaceSlug ? WORKSPACE_INTEGRATIONS(workspaceSlug) : null, - workspaceSlug ? () => integrationService.getWorkspaceIntegrationsList(workspaceSlug) : null - ); - - const activeIntegrationState = () => { - const currentElementIndex = integrationWorkflowData.findIndex((i) => i?.key === currentStep?.state); - - return currentElementIndex; - }; - - const handleStepChange = (value: TIntegrationSteps) => { - setCurrentStep((prevData) => ({ ...prevData, state: value })); - }; - - // current integration from all the integrations available - const integration = - appIntegrations && appIntegrations.length > 0 && appIntegrations.find((i) => i.provider === provider); - - // current integration from workspace integrations - const workspaceIntegration = - integration && workspaceIntegrations?.find((i: any) => i.integration_detail.id === integration.id); - - const createGithubImporterService = async (formData: TFormValues) => { - if (!formData.github || !formData.project) return; - - const payload: IGithubServiceImportFormData = { - metadata: { - owner: formData.github.owner.login, - name: formData.github.name, - repository_id: formData.github.id, - url: formData.github.html_url, - }, - data: { - users: users, - }, - config: { - sync: formData.sync, - }, - project_id: formData.project, - }; - - await githubIntegrationService - .createGithubServiceImport(workspaceSlug, payload) - .then(() => { - router.push(`/${workspaceSlug}/settings/imports`); - mutate(IMPORTER_SERVICES_LIST(workspaceSlug)); - }) - .catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Import was unsuccessful. Please try again.", - }) - ); - }; - - return ( -
-
- - - -
Cancel import & go back
-
- - -
-
-
- GitHubLogo -
-
- {integrationWorkflowData.map((integration, index) => ( - -
- -
- {index < integrationWorkflowData.length - 1 && ( -
- {" "} -
- )} -
- ))} -
-
- -
-
- {currentStep?.state === "import-configure" && ( - - )} - {currentStep?.state === "import-data" && ( - - )} - {currentStep?.state === "repo-details" && ( - - )} - {currentStep?.state === "import-users" && ( - - )} - {currentStep?.state === "import-confirm" && ( - - )} -
-
-
-
-
- ); -} diff --git a/apps/web/core/components/integration/github/single-user-select.tsx b/apps/web/core/components/integration/github/single-user-select.tsx deleted file mode 100644 index 09b417f5e3..0000000000 --- a/apps/web/core/components/integration/github/single-user-select.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useParams } from "next/navigation"; -import useSWR from "swr"; -// plane types -import type { IGithubRepoCollaborator } from "@plane/types"; -// plane ui -import { Avatar, CustomSelect, CustomSearchSelect, Input } from "@plane/ui"; -// constants -import { getFileURL } from "@plane/utils"; -import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys"; -// helpers -// plane web services -import { WorkspaceService } from "@/plane-web/services"; -// types -import type { IUserDetails } from "./root"; - -type Props = { - collaborator: IGithubRepoCollaborator; - index: number; - users: IUserDetails[]; - setUsers: React.Dispatch>; -}; - -const importOptions = [ - { - key: "map", - label: "Map to existing", - }, - { - key: "invite", - label: "Invite by email", - }, - { - key: false, - label: "Do not import", - }, -]; - -// services -const workspaceService = new WorkspaceService(); - -export function SingleUserSelect({ collaborator, index, users, setUsers }: Props) { - const { workspaceSlug } = useParams(); - - const { data: members } = useSWR( - workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug.toString()) : null, - workspaceSlug ? () => workspaceService.fetchWorkspaceMembers(workspaceSlug.toString()) : null - ); - - const options = members - ?.map((member) => { - if (!member?.member) return; - return { - value: member.member?.display_name, - query: member.member?.display_name ?? "", - content: ( -
- - {member.member?.display_name} -
- ), - }; - }) - .filter((member) => !!member) as - | { - value: string; - query: string; - content: React.ReactNode; - }[] - | undefined; - - return ( -
-
-
- {`${collaborator.login} -
-

{collaborator.login}

-
-
- {importOptions.find((o) => o.key === users[index].import)?.label}
} - onChange={(val: any) => { - const newUsers = [...users]; - newUsers[index].import = val; - newUsers[index].email = ""; - setUsers(newUsers); - }} - noChevron - > - {importOptions.map((option) => ( - -
{option.label}
-
- ))} - -
- {users[index].import === "invite" && ( - { - const newUsers = [...users]; - newUsers[index].email = e.target.value; - setUsers(newUsers); - }} - placeholder="Enter email of the user" - className="w-full py-1 text-11" - /> - )} - {users[index].import === "map" && members && ( - { - const newUsers = [...users]; - newUsers[index].email = val; - setUsers(newUsers); - }} - optionsClassName="w-48" - /> - )} -
- ); -} diff --git a/apps/web/core/components/integration/guide.tsx b/apps/web/core/components/integration/guide.tsx deleted file mode 100644 index eac78513f6..0000000000 --- a/apps/web/core/components/integration/guide.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useState } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams, useSearchParams } from "next/navigation"; -import useSWR, { mutate } from "swr"; -// icons -import { RefreshCw } from "lucide-react"; -// plane imports -import { IMPORTERS_LIST } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -// types -import { Button } from "@plane/propel/button"; -import type { IImporterService } from "@plane/types"; -// assets -import GithubLogo from "@/app/assets/services/github.png?url"; -import JiraLogo from "@/app/assets/services/jira.svg?url"; -// components -import { DeleteImportModal, GithubImporterRoot, JiraImporterRoot, SingleImport } from "@/components/integration"; -import { ImportExportSettingsLoader } from "@/components/ui/loader/settings/import-and-export"; -// constants -import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys"; -// hooks -import { useUser } from "@/hooks/store/user"; -// services -import { IntegrationService } from "@/services/integrations"; - -// services -const integrationService = new IntegrationService(); - -const getImporterLogo = (provider: string) => { - switch (provider) { - case "github": - return GithubLogo; - case "jira": - return JiraLogo; - default: - return ""; - } -}; - -// FIXME: [Deprecated] Remove this component -const IntegrationGuide = observer(function IntegrationGuide() { - // states - const [refreshing, setRefreshing] = useState(false); - const [deleteImportModal, setDeleteImportModal] = useState(false); - const [importToDelete, setImportToDelete] = useState(null); - // router - const { workspaceSlug } = useParams(); - const searchParams = useSearchParams(); - const provider = searchParams.get("provider"); - // store hooks - const { data: currentUser } = useUser(); - - const { t } = useTranslation(); - - const { data: importerServices } = useSWR( - workspaceSlug ? IMPORTER_SERVICES_LIST(workspaceSlug) : null, - workspaceSlug ? () => integrationService.getImporterServicesList(workspaceSlug) : null - ); - - const handleDeleteImport = (importService: IImporterService) => { - setImportToDelete(importService); - setDeleteImportModal(true); - }; - - return ( - <> - setDeleteImportModal(false)} - data={importToDelete} - user={currentUser || null} - /> -
- {(!provider || provider === "csv") && ( - <> - {/*
-
-
Relocation Guide
-
- You can now transfer all the work items that you{"'"}ve created in other tracking - services. This tool will guide you to relocate the work item to Plane. -
-
- -
- Read More - -
-
-
*/} - {IMPORTERS_LIST.map((service) => ( -
-
-
- {`${t(service.i18n_title)} -
-
-

{t(service.i18n_title)}

-

{t(service.i18n_description)}

-
-
-
- - - - - -
-
- ))} -
-
-

- Previous Imports - -

-
-
- {importerServices ? ( - importerServices.length > 0 ? ( -
-
- {importerServices.map((service) => ( - handleDeleteImport(service)} - /> - ))} -
-
- ) : ( -
- {/* */} -
- ) - ) : ( - - )} -
-
- - )} - - {provider && provider === "github" && } - {provider && provider === "jira" && } -
- - ); -}); - -export default IntegrationGuide; - -export { IntegrationGuide }; diff --git a/apps/web/core/components/integration/index.ts b/apps/web/core/components/integration/index.ts deleted file mode 100644 index a734113b6c..0000000000 --- a/apps/web/core/components/integration/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -// layout -export * from "./delete-import-modal"; -export * from "./guide"; -export * from "./single-import"; -export * from "./single-integration-card"; - -// github -export * from "./github"; -// jira -export * from "./jira"; -// slack -export * from "./slack"; diff --git a/apps/web/core/components/integration/jira/confirm-import.tsx b/apps/web/core/components/integration/jira/confirm-import.tsx deleted file mode 100644 index 15cc0da26b..0000000000 --- a/apps/web/core/components/integration/jira/confirm-import.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from "react"; - -// react hook form -import { useFormContext } from "react-hook-form"; -import type { IJiraImporterForm } from "@plane/types"; - -// types - -export function JiraConfirmImport() { - const { watch } = useFormContext(); - - return ( -
-
-
-

Confirm

-
- -
-

Migrating

-
-
-
-

{watch("data.total_issues")}

-

Work items

-
-
-

{watch("data.total_states")}

-

States

-
-
-

{watch("data.total_modules")}

-

Modules

-
-
-

{watch("data.total_labels")}

-

Labels

-
-
-

{watch("data.users").filter((user) => user.import).length}

-

User

-
-
-
-
- ); -} diff --git a/apps/web/core/components/integration/jira/give-details.tsx b/apps/web/core/components/integration/jira/give-details.tsx deleted file mode 100644 index 65601beecc..0000000000 --- a/apps/web/core/components/integration/jira/give-details.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useFormContext, Controller } from "react-hook-form"; -import { PlusIcon } from "@plane/propel/icons"; -import type { IJiraImporterForm } from "@plane/types"; -// hooks -// components -import { CustomSelect, Input } from "@plane/ui"; -// helpers -import { checkEmailValidity } from "@plane/utils"; -import { useCommandPalette } from "@/hooks/store/use-command-palette"; -import { useProject } from "@/hooks/store/use-project"; -// types - -export const JiraGetImportDetail = observer(function JiraGetImportDetail() { - // store hooks - const { toggleCreateProjectModal } = useCommandPalette(); - const { workspaceProjectIds, getProjectById } = useProject(); - // form info - const { - control, - formState: { errors }, - } = useFormContext(); - - return ( -
-
-
-

Jira Personal Access Token

-

- Get to know your access token by navigating to{" "} - - Atlassian Settings - -

-
- -
- ( - - )} - /> - {errors.metadata?.api_token && ( -

{errors.metadata.api_token.message}

- )} -
-
-
-
-

Jira Project Key

-

If XXX-123 is your work item, then enter XXX

-
-
- ( - - )} - /> - {errors.metadata?.project_key && ( -

{errors.metadata.project_key.message}

- )} -
-
-
-
-

Jira Email Address

-

Enter the Email account that you use in Jira account

-
-
- checkEmailValidity(value) || "Please enter a valid email address", - }} - render={({ field: { value, onChange, ref } }) => ( - - )} - /> - {errors.metadata?.email &&

{errors.metadata.email.message}

} -
-
-
-
-

Jira Installation or Cloud Host Name

-

Enter your companies cloud host name

-
-
- !/^https?:\/\//.test(value) || "Hostname should not begin with http:// or https://", - }} - render={({ field: { value, onChange, ref } }) => ( - - )} - /> - {errors.metadata?.cloud_hostname && ( -

{errors.metadata.cloud_hostname.message}

- )} -
-
-
-
-

Import to project

-

Select which project you want to import to.

-
-
- ( - - {value && value.trim() !== "" ? ( - getProjectById(value)?.name - ) : ( - Select a project - )} - - } - > - {workspaceProjectIds && workspaceProjectIds.length > 0 ? ( - workspaceProjectIds.map((projectId) => { - const projectDetails = getProjectById(projectId); - - if (!projectDetails) return; - - return ( - - {projectDetails.name} - - ); - }) - ) : ( -
-

You don{"'"}t have any project. Please create a project first.

-
- )} -
- -
-
- )} - /> -
-
-
- ); -}); diff --git a/apps/web/core/components/integration/jira/import-users.tsx b/apps/web/core/components/integration/jira/import-users.tsx deleted file mode 100644 index 9f64f53496..0000000000 --- a/apps/web/core/components/integration/jira/import-users.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import type { FC } from "react"; -import { useParams } from "next/navigation"; -import { useFormContext, useFieldArray, Controller } from "react-hook-form"; -import useSWR from "swr"; -// plane types -import type { IJiraImporterForm } from "@plane/types"; -// plane ui -import { Avatar, CustomSelect, CustomSearchSelect, Input, ToggleSwitch } from "@plane/ui"; -// constants -import { getFileURL } from "@plane/utils"; -import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys"; -// helpers -// plane web services -import { WorkspaceService } from "@/plane-web/services"; - -const workspaceService = new WorkspaceService(); - -export function JiraImportUsers() { - const { workspaceSlug } = useParams(); - // form info - const { - control, - watch, - formState: { errors }, - } = useFormContext(); - - const { fields } = useFieldArray({ - control, - name: "data.users", - }); - - const { data: members } = useSWR( - workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug?.toString() ?? "") : null, - workspaceSlug ? () => workspaceService.fetchWorkspaceMembers(workspaceSlug?.toString() ?? "") : null - ); - - const options = members - ?.map((member) => { - if (!member?.member) return; - return { - value: member.member.email, - query: member.member.display_name ?? "", - content: ( -
- - {member.member.display_name} -
- ), - }; - }) - .filter((member) => !!member) as - | { - value: string; - query: string; - content: React.ReactNode; - }[] - | undefined; - - return ( -
-
-
-

Users

-

Update, invite or choose not to invite assignee

-
-
- } - /> -
-
- - {watch("data.invite_users") && ( -
-
-
Name
-
Import as
-
- -
- {fields.map((user, index) => ( -
-
-

{user.username}

-
-
- ( - {value ? value : ("Ignore" as any)}} - > - Invite by email - Map to existing - Do not import - - )} - /> -
-
- {watch(`data.users.${index}.import`) === "invite" && ( - ( - - )} - /> - )} - {watch(`data.users.${index}.import`) === "map" && ( - ( - - )} - /> - )} -
-
- ))} -
-
- )} -
- ); -} diff --git a/apps/web/core/components/integration/jira/index.ts b/apps/web/core/components/integration/jira/index.ts deleted file mode 100644 index bc22a82461..0000000000 --- a/apps/web/core/components/integration/jira/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -export * from "./root"; -export * from "./give-details"; -export * from "./jira-project-detail"; -export * from "./import-users"; -export * from "./confirm-import"; - -import type { IJiraImporterForm } from "@plane/types"; - -export type TJiraIntegrationSteps = - | "import-configure" - | "display-import-data" - | "select-import-data" - | "import-users" - | "import-confirmation"; - -export interface IJiraIntegrationData { - state: TJiraIntegrationSteps; -} - -export const jiraFormDefaultValues: IJiraImporterForm = { - metadata: { - cloud_hostname: "", - api_token: "", - project_key: "", - email: "", - }, - config: { - epics_to_modules: false, - }, - data: { - users: [], - invite_users: true, - total_issues: 0, - total_labels: 0, - total_modules: 0, - total_states: 0, - }, - project_id: "", -}; diff --git a/apps/web/core/components/integration/jira/jira-project-detail.tsx b/apps/web/core/components/integration/jira/jira-project-detail.tsx deleted file mode 100644 index 2fc79180b3..0000000000 --- a/apps/web/core/components/integration/jira/jira-project-detail.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import React, { useEffect } from "react"; - -// next -import { useParams } from "next/navigation"; - -// swr -import { useFormContext, Controller } from "react-hook-form"; -import useSWR from "swr"; -import type { IJiraImporterForm, IJiraMetadata } from "@plane/types"; - -// react hook form - -// services -import { ToggleSwitch, Spinner } from "@plane/ui"; -import { JIRA_IMPORTER_DETAIL } from "@/constants/fetch-keys"; -import { JiraImporterService } from "@/services/integrations"; - -// fetch keys - -// components - -import type { IJiraIntegrationData, TJiraIntegrationSteps } from "."; - -type Props = { - setCurrentStep: React.Dispatch>; - setDisableTopBarAfter: React.Dispatch>; -}; - -// services -const jiraImporterService = new JiraImporterService(); - -export function JiraProjectDetail(props: Props) { - const { setCurrentStep, setDisableTopBarAfter } = props; - - const { - watch, - setValue, - control, - formState: { errors }, - } = useFormContext(); - - const { workspaceSlug } = useParams(); - - const params: IJiraMetadata = { - api_token: watch("metadata.api_token"), - project_key: watch("metadata.project_key"), - email: watch("metadata.email"), - cloud_hostname: watch("metadata.cloud_hostname"), - }; - - const { data: projectInfo, error } = useSWR( - workspaceSlug && - !errors.metadata?.api_token && - !errors.metadata?.project_key && - !errors.metadata?.email && - !errors.metadata?.cloud_hostname - ? JIRA_IMPORTER_DETAIL(workspaceSlug.toString(), params) - : null, - workspaceSlug && - !errors.metadata?.api_token && - !errors.metadata?.project_key && - !errors.metadata?.email && - !errors.metadata?.cloud_hostname - ? () => jiraImporterService.getJiraProjectInfo(workspaceSlug.toString(), params) - : null - ); - - useEffect(() => { - if (!projectInfo) return; - - setValue("data.total_issues", projectInfo.issues); - setValue("data.total_labels", projectInfo.labels); - setValue( - "data.users", - projectInfo.users?.map((user) => ({ - email: user.emailAddress, - import: false, - username: user.displayName, - })) - ); - setValue("data.total_states", projectInfo.states); - setValue("data.total_modules", projectInfo.modules); - }, [projectInfo, setValue]); - - useEffect(() => { - if (error) setDisableTopBarAfter("display-import-data"); - else setDisableTopBarAfter(null); - }, [error, setDisableTopBarAfter]); - - useEffect(() => { - if (!projectInfo && !error) setDisableTopBarAfter("display-import-data"); - else if (!error) setDisableTopBarAfter(null); - }, [projectInfo, error, setDisableTopBarAfter]); - - if (!projectInfo && !error) { - return ( -
- -
- ); - } - - if (error) { - return ( -
-

- Something went wrong. Please{" "} - {" "} - and check your Jira project details. -

-
- ); - } - - return ( -
-
-
-

Import Data

-

Import Completed. We have found:

-
-
-
-

{projectInfo?.issues}

-

Work items

-
-
-

{projectInfo?.states}

-

States

-
-
-

{projectInfo?.modules}

-

Modules

-
-
-

{projectInfo?.labels}

-

Labels

-
-
-

{projectInfo?.users?.length}

-

Users

-
-
-
- -
-
-

Import Epics

-

Import epics as modules

-
-
- } - /> -
-
-
- ); -} diff --git a/apps/web/core/components/integration/jira/root.tsx b/apps/web/core/components/integration/jira/root.tsx deleted file mode 100644 index 2941d5ff5e..0000000000 --- a/apps/web/core/components/integration/jira/root.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import React, { useState } from "react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { FormProvider, useForm } from "react-hook-form"; -import { mutate } from "swr"; -// icons -import { ArrowLeft, List, Settings } from "lucide-react"; -import { Button } from "@plane/propel/button"; -import { CheckIcon, MembersPropertyIcon } from "@plane/propel/icons"; -// types -import type { IJiraImporterForm } from "@plane/types"; -// assets -import JiraLogo from "@/app/assets/services/jira.svg?url"; -// fetch keys -import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys"; -// hooks -import { useAppRouter } from "@/hooks/use-app-router"; -// services -import { JiraImporterService } from "@/services/integrations"; -// components -import type { TJiraIntegrationSteps, IJiraIntegrationData } from "."; -import { JiraGetImportDetail, JiraProjectDetail, JiraImportUsers, JiraConfirmImport, jiraFormDefaultValues } from "."; - -const integrationWorkflowData: Array<{ - title: string; - key: TJiraIntegrationSteps; - icon: any; -}> = [ - { - title: "Configure", - key: "import-configure", - icon: Settings, - }, - { - title: "Import Data", - key: "display-import-data", - icon: List, - }, - { - title: "Users", - key: "import-users", - icon: MembersPropertyIcon, - }, - { - title: "Confirm", - key: "import-confirmation", - icon: CheckIcon, - }, -]; - -// services -const jiraImporterService = new JiraImporterService(); - -export function JiraImporterRoot() { - const [currentStep, setCurrentStep] = useState({ - state: "import-configure", - }); - const [disableTopBarAfter, setDisableTopBarAfter] = useState(null); - - const router = useAppRouter(); - const { workspaceSlug } = useParams(); - - const methods = useForm({ - defaultValues: jiraFormDefaultValues, - mode: "all", - reValidateMode: "onChange", - }); - - const isValid = methods.formState.isValid; - - const onSubmit = async (data: IJiraImporterForm) => { - if (!workspaceSlug) return; - - await jiraImporterService - .createJiraImporter(workspaceSlug.toString(), data) - .then(() => { - mutate(IMPORTER_SERVICES_LIST(workspaceSlug.toString())); - router.push(`/${workspaceSlug}/settings/imports`); - }) - .catch((err) => { - console.error(err); - }); - }; - - const activeIntegrationState = () => { - const currentElementIndex = integrationWorkflowData.findIndex((i) => i?.key === currentStep?.state); - - return currentElementIndex; - }; - - return ( -
- - -
- -
-
Cancel import & go back
-
- - -
-
-
- jira logo -
-
- {integrationWorkflowData.map((integration, index) => ( - - - {index < integrationWorkflowData.length - 1 && ( -
- {" "} -
- )} -
- ))} -
-
- -
- -
-
- {currentStep.state === "import-configure" && } - {currentStep.state === "display-import-data" && ( - - )} - {currentStep?.state === "import-users" && } - {currentStep?.state === "import-confirmation" && } -
- -
- {currentStep?.state !== "import-configure" && ( - - )} - -
-
-
-
-
-
- ); -} diff --git a/apps/web/core/components/integration/single-import.tsx b/apps/web/core/components/integration/single-import.tsx deleted file mode 100644 index 43241ed2d6..0000000000 --- a/apps/web/core/components/integration/single-import.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { observer } from "mobx-react"; - -// plane imports -import { IMPORTERS_LIST } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { TrashIcon } from "@plane/propel/icons"; -import type { IImporterService } from "@plane/types"; -import { CustomMenu } from "@plane/ui"; -// icons -// helpers - -import { renderFormattedDate } from "@plane/utils"; -// types -// constants - -type Props = { - service: IImporterService; - refreshing: boolean; - handleDelete: () => void; -}; - -export const SingleImport = observer(function SingleImport({ service, refreshing, handleDelete }: Props) { - const { t } = useTranslation(); - - const importer = IMPORTERS_LIST.find((i) => i.provider === service.service); - return ( -
-
-

- {importer && ( - - Import from {t(importer.i18n_title)} to{" "} - - )} - {service.project_detail.name} - - {refreshing ? "Refreshing..." : service.status} - -

-
- {renderFormattedDate(service.created_at)}| - Imported by {service.initiated_by_detail?.display_name} -
-
- - - - - Delete import - - - -
- ); -}); diff --git a/apps/web/core/components/integration/slack/index.ts b/apps/web/core/components/integration/slack/index.ts deleted file mode 100644 index 4ea6cd1e4b..0000000000 --- a/apps/web/core/components/integration/slack/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./select-channel"; diff --git a/apps/web/core/components/labels/project-setting-label-list.tsx b/apps/web/core/components/labels/project-setting-label-list.tsx index 1791c003b9..2eb7dc488d 100644 --- a/apps/web/core/components/labels/project-setting-label-list.tsx +++ b/apps/web/core/components/labels/project-setting-label-list.tsx @@ -4,6 +4,7 @@ import { useParams } from "next/navigation"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; import { EmptyStateCompact } from "@plane/propel/empty-state"; import type { IIssueLabel } from "@plane/types"; import { Loader } from "@plane/ui"; @@ -76,16 +77,15 @@ export const ProjectSettingsLabelList = observer(function ProjectSettingsLabelLi { - newLabel(); - }, - }} - showButton={isEditable} + control={ + isEditable && ( + + ) + } /> - -
+
{showLabelForm && (
) : ( - projectLabelsTree && ( -
- {projectLabelsTree.map((label, index) => { - if (label.children && label.children.length) { - return ( - setSelectDeleteLabel(label)} - isUpdating={isUpdating} - setIsUpdating={setIsUpdating} - isLastChild={index === projectLabelsTree.length - 1} - onDrop={onDrop} - isEditable={isEditable} - labelOperationsCallbacks={labelOperationsCallbacks} - /> - ); - } - return ( - setSelectDeleteLabel(label)} - isChild={false} - isLastChild={index === projectLabelsTree.length - 1} - onDrop={onDrop} - isEditable={isEditable} - labelOperationsCallbacks={labelOperationsCallbacks} - /> - ); - })} -
- ) + projectLabelsTree?.map((label, index) => { + if (label.children && label.children.length) { + return ( + setSelectDeleteLabel(label)} + isUpdating={isUpdating} + setIsUpdating={setIsUpdating} + isLastChild={index === projectLabelsTree.length - 1} + onDrop={onDrop} + isEditable={isEditable} + labelOperationsCallbacks={labelOperationsCallbacks} + /> + ); + } + return ( + setSelectDeleteLabel(label)} + isChild={false} + isLastChild={index === projectLabelsTree.length - 1} + onDrop={onDrop} + isEditable={isEditable} + labelOperationsCallbacks={labelOperationsCallbacks} + /> + ); + }) ) ) : ( !showLabelForm && ( diff --git a/apps/web/core/components/navigation/app-rail-root.tsx b/apps/web/core/components/navigation/app-rail-root.tsx index bf096b59d3..eb37e7d212 100644 --- a/apps/web/core/components/navigation/app-rail-root.tsx +++ b/apps/web/core/components/navigation/app-rail-root.tsx @@ -17,13 +17,13 @@ import { AppSidebarItemsRoot } from "./items-root"; export const AppRailRoot = observer(() => { // router - const { workspaceSlug } = useParams(); + const { workspaceSlug, projectId } = useParams(); const pathname = usePathname(); // preferences const { preferences, updateDisplayMode } = useAppRailPreferences(); const { isCollapsed, toggleAppRail } = useAppRailVisibility(); - - const isSettingsPath = pathname.includes(`/${workspaceSlug}/settings`); + // derived values + const isWorkspaceSettingsPath = pathname.includes(`/${workspaceSlug}/settings`) && !projectId; const showLabel = preferences.displayMode === "icon_with_label"; const railWidth = showLabel ? "3.75rem" : "3rem"; @@ -52,7 +52,7 @@ export const AppRailRoot = observer(() => { label: "Settings", icon: , href: `/${workspaceSlug}/settings`, - isActive: isSettingsPath, + isActive: isWorkspaceSettingsPath, showLabel, }} /> diff --git a/apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx index 109bb67ef8..25920d8d42 100644 --- a/apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/open-entity/project-settings-menu.tsx @@ -1,13 +1,13 @@ import { observer } from "mobx-react"; -// plane types -import { EUserPermissionsLevel } from "@plane/constants"; -// components +// plane imports +import { EUserPermissionsLevel, PROJECT_SETTINGS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +// components import type { TPowerKContext } from "@/components/power-k/core/types"; import { PowerKSettingsMenu } from "@/components/power-k/menus/settings"; +import { PROJECT_SETTINGS_ICONS } from "@/components/settings/project/sidebar/item-icon"; // hooks import { useUserPermissions } from "@/hooks/store/user"; -import { PROJECT_SETTINGS } from "@/plane-web/constants/project"; type Props = { context: TPowerKContext; @@ -35,7 +35,7 @@ export const PowerKOpenProjectSettingsMenu = observer(function PowerKOpenProject const settingsListWithIcons = settingsList.map((setting) => ({ ...setting, label: t(setting.i18n_label), - icon: setting.Icon, + icon: PROJECT_SETTINGS_ICONS[setting.key], })); return handleSelect(setting.href)} />; diff --git a/apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx b/apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx index 8fd3a50389..c4e9997ec5 100644 --- a/apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx +++ b/apps/web/core/components/power-k/ui/pages/open-entity/workspace-settings-menu.tsx @@ -5,10 +5,11 @@ import { EUserPermissionsLevel, WORKSPACE_SETTINGS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import type { TPowerKContext } from "@/components/power-k/core/types"; import { PowerKSettingsMenu } from "@/components/power-k/menus/settings"; +import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; // hooks import { useUserPermissions } from "@/hooks/store/user"; +// plane web imports import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; -import { WORKSPACE_SETTINGS_ICONS } from "app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar"; type Props = { context: TPowerKContext; @@ -31,7 +32,7 @@ export const PowerKOpenWorkspaceSettingsMenu = observer(function PowerKOpenWorks const settingsListWithIcons = settingsList.map((setting) => ({ ...setting, label: t(setting.i18n_label), - icon: WORKSPACE_SETTINGS_ICONS[setting.key as keyof typeof WORKSPACE_SETTINGS_ICONS], + icon: WORKSPACE_SETTINGS_ICONS[setting.key], })); return handleSelect(setting.href)} />; diff --git a/apps/web/core/components/preferences/list.tsx b/apps/web/core/components/preferences/list.tsx deleted file mode 100644 index 291eebf01e..0000000000 --- a/apps/web/core/components/preferences/list.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { PREFERENCE_OPTIONS } from "@plane/constants"; -import { PREFERENCE_COMPONENTS } from "@/plane-web/components/preferences/config"; - -export function PreferencesList() { - return ( -
- {PREFERENCE_OPTIONS.map((option) => { - const Component = PREFERENCE_COMPONENTS[option.id as keyof typeof PREFERENCE_COMPONENTS]; - return ; - })} -
- ); -} diff --git a/apps/web/core/components/preferences/section.tsx b/apps/web/core/components/preferences/section.tsx deleted file mode 100644 index f69c600bd2..0000000000 --- a/apps/web/core/components/preferences/section.tsx +++ /dev/null @@ -1,17 +0,0 @@ -interface SettingsSectionProps { - title: string; - description: string; - control: React.ReactNode; -} - -export function PreferencesSection({ title, description, control }: SettingsSectionProps) { - return ( -
-
-

{title}

-

{description}

-
-
{control}
-
- ); -} diff --git a/apps/web/core/components/profile/notification/email-notification-form.tsx b/apps/web/core/components/profile/notification/email-notification-form.tsx deleted file mode 100644 index 8ab4fac880..0000000000 --- a/apps/web/core/components/profile/notification/email-notification-form.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { useTranslation } from "@plane/i18n"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import type { IUserEmailNotificationSettings } from "@plane/types"; -// ui -import { ToggleSwitch } from "@plane/ui"; -// services -import { UserService } from "@/services/user.service"; -// types -interface IEmailNotificationFormProps { - data: IUserEmailNotificationSettings; -} - -// services -const userService = new UserService(); - -export function EmailNotificationForm(props: IEmailNotificationFormProps) { - const { data } = props; - const { t } = useTranslation(); - // form data - const { control, reset } = useForm({ - defaultValues: { - ...data, - }, - }); - - const handleSettingChange = async (key: keyof IUserEmailNotificationSettings, value: boolean) => { - try { - await userService.updateCurrentUserEmailNotificationSettings({ - [key]: value, - }); - setToast({ - title: t("success"), - type: TOAST_TYPE.SUCCESS, - message: t("email_notification_setting_updated_successfully"), - }); - } catch (_error) { - setToast({ - title: t("error"), - type: TOAST_TYPE.ERROR, - message: t("failed_to_update_email_notification_setting"), - }); - } - }; - - useEffect(() => { - reset(data); - }, [reset, data]); - - return ( - <> - {/* Notification Settings */} -
-
-
-
{t("property_changes")}
-
{t("property_changes_description")}
-
-
- ( - { - onChange(newValue); - handleSettingChange("property_change", newValue); - }} - size="sm" - /> - )} - /> -
-
-
-
-
{t("state_change")}
-
{t("state_change_description")}
-
-
- ( - { - onChange(newValue); - handleSettingChange("state_change", newValue); - }} - size="sm" - /> - )} - /> -
-
-
-
-
{t("issue_completed")}
-
{t("issue_completed_description")}
-
-
- ( - { - onChange(newValue); - handleSettingChange("issue_completed", newValue); - }} - size="sm" - /> - )} - /> -
-
-
-
-
{t("comments")}
-
{t("comments_description")}
-
-
- ( - { - onChange(newValue); - handleSettingChange("comment", newValue); - }} - size="sm" - /> - )} - /> -
-
-
-
-
{t("mentions")}
-
{t("mentions_description")}
-
-
- ( - { - onChange(newValue); - handleSettingChange("mention", newValue); - }} - size="sm" - /> - )} - /> -
-
-
- - ); -} diff --git a/apps/web/core/components/profile/preferences/language-timezone.tsx b/apps/web/core/components/profile/preferences/language-timezone.tsx deleted file mode 100644 index ef2de25298..0000000000 --- a/apps/web/core/components/profile/preferences/language-timezone.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { observer } from "mobx-react"; -import { SUPPORTED_LANGUAGES, useTranslation } from "@plane/i18n"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { CustomSelect } from "@plane/ui"; -import { TimezoneSelect } from "@/components/global"; -import { useUser, useUserProfile } from "@/hooks/store/user"; - -export const LanguageTimezone = observer(function LanguageTimezone() { - // store hooks - const { - data: user, - updateCurrentUser, - userProfile: { data: profile }, - } = useUser(); - const { updateUserProfile } = useUserProfile(); - const { t } = useTranslation(); - - const handleTimezoneChange = async (value: string) => { - try { - await updateCurrentUser({ user_timezone: value }); - setToast({ - title: "Success!", - message: "Timezone updated successfully", - type: TOAST_TYPE.SUCCESS, - }); - } catch (_error) { - setToast({ - title: "Error!", - message: "Failed to update timezone", - type: TOAST_TYPE.ERROR, - }); - } - }; - - const handleLanguageChange = async (value: string) => { - try { - await updateUserProfile({ language: value }); - setToast({ - title: "Success!", - message: "Language updated successfully", - type: TOAST_TYPE.SUCCESS, - }); - } catch (_error) { - setToast({ - title: "Error!", - message: "Failed to update language", - type: TOAST_TYPE.ERROR, - }); - } - }; - - const getLanguageLabel = (value: string) => { - const selectedLanguage = SUPPORTED_LANGUAGES.find((l) => l.value === value); - if (!selectedLanguage) return value; - return selectedLanguage.label; - }; - - return ( -
-
-
-
-
-

{t("timezone")} 

-

{t("timezone_setting")}

-
-
- -
-
-
-
-
-
-

{t("language")} 

-

{t("language_setting")}

-
-
- - {SUPPORTED_LANGUAGES.map((item) => ( - - {item.label} - - ))} - -
-
-
-
-
- ); -}); diff --git a/apps/web/core/components/profile/profile-setting-content-header.tsx b/apps/web/core/components/profile/profile-setting-content-header.tsx deleted file mode 100644 index 16e233a66f..0000000000 --- a/apps/web/core/components/profile/profile-setting-content-header.tsx +++ /dev/null @@ -1,14 +0,0 @@ -type Props = { - title: string; - description?: string; -}; - -export function ProfileSettingContentHeader(props: Props) { - const { title, description } = props; - return ( -
-
{title}
- {description &&
{description}
} -
- ); -} diff --git a/apps/web/core/components/profile/sidebar.tsx b/apps/web/core/components/profile/sidebar.tsx index 611fd6e53e..4d7633051e 100644 --- a/apps/web/core/components/profile/sidebar.tsx +++ b/apps/web/core/components/profile/sidebar.tsx @@ -1,26 +1,22 @@ import { useEffect, useRef } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; import { useParams } from "next/navigation"; -// icons - -// headless ui import { Disclosure, Transition } from "@headlessui/react"; -// plane helpers +// plane imports import { useOutsideClickDetector } from "@plane/hooks"; -// types import { useTranslation } from "@plane/i18n"; import { Logo } from "@plane/propel/emoji-icon-picker"; +import { IconButton } from "@plane/propel/icon-button"; import { EditIcon, ChevronDownIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import type { IUserProfileProjectSegregation } from "@plane/types"; -// plane ui import { Loader } from "@plane/ui"; import { cn, renderFormattedDate, getFileURL } from "@plane/utils"; // components import { CoverImage } from "@/components/common/cover-image"; // hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useProject } from "@/hooks/store/use-project"; import { useUser } from "@/hooks/store/user"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -37,11 +33,12 @@ export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSi // refs const ref = useRef(null); // router - const { userId, workspaceSlug } = useParams(); + const { userId } = useParams(); // store hooks const { data: currentUser } = useUser(); const { profileSidebarCollapsed, toggleProfileSidebar } = useAppTheme(); const { getProjectById } = useProject(); + const { toggleProfileSettingsModal } = useCommandPalette(); const { isMobile } = usePlatformOS(); const { t } = useTranslation(); // derived values @@ -84,7 +81,7 @@ export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSi return (
{currentUser?.id === userId && ( -
- - - - - +
+ + toggleProfileSettingsModal({ + activeTab: "general", + isOpen: true, + }) + } + />
)} START_OF_THE_WEEK_OPTIONS.find((option) => option.value === startOfWeek)?.label; @@ -27,27 +28,27 @@ export const StartOfWeekPreference = observer(function StartOfWeekPreference(pro }; return ( - - - <> - {START_OF_THE_WEEK_OPTIONS.map((day) => ( - - {day.label} - - ))} - - -
+ + <> + {START_OF_THE_WEEK_OPTIONS.map((day) => ( + + {day.label} + + ))} + + } /> ); diff --git a/apps/web/core/components/project-states/root.tsx b/apps/web/core/components/project-states/root.tsx index d9dbfc9761..1143b268b4 100644 --- a/apps/web/core/components/project-states/root.tsx +++ b/apps/web/core/components/project-states/root.tsx @@ -62,13 +62,11 @@ export const ProjectStateRoot = observer(function ProjectStateRoot(props: TProje if (!groupedProjectStates) return ; return ( -
- -
+ ); }); diff --git a/apps/web/core/components/project/settings/archive-project/archive-restore-modal.tsx b/apps/web/core/components/project/archive-restore-modal.tsx similarity index 100% rename from apps/web/core/components/project/settings/archive-project/archive-restore-modal.tsx rename to apps/web/core/components/project/archive-restore-modal.tsx diff --git a/apps/web/core/components/project/card.tsx b/apps/web/core/components/project/card.tsx index d4ee4bc905..9c7dcd7b56 100644 --- a/apps/web/core/components/project/card.tsx +++ b/apps/web/core/components/project/card.tsx @@ -26,7 +26,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; import { CoverImage } from "@/components/common/cover-image"; import { DeleteProjectModal } from "./delete-project-modal"; import { JoinProjectModal } from "./join-project-modal"; -import { ArchiveRestoreProjectModal } from "./settings/archive-project/archive-restore-modal"; +import { ArchiveRestoreProjectModal } from "./archive-restore-modal"; type Props = { project: IProject; diff --git a/apps/web/core/components/project/form.tsx b/apps/web/core/components/project/form.tsx index 634275c40f..527dab3224 100644 --- a/apps/web/core/components/project/form.tsx +++ b/apps/web/core/components/project/form.tsx @@ -258,7 +258,7 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
-
+

{t("common.project_name")}

void; -} - -export function ArchiveProjectSelection(props: IArchiveProject) { - const { projectDetails, handleArchive } = props; - - return ( - - {({ open }) => ( -
- - Archive project - {open ? : } - - - -
- - Archiving a project will unlist your project from your side navigation although you will still be able - to access it from your projects page. You can restore the project or delete it whenever you want. - -
- {projectDetails ? ( -
- -
- ) : ( - - - - )} -
-
-
-
-
- )} -
- ); -} diff --git a/apps/web/core/components/project/settings/control-section.tsx b/apps/web/core/components/project/settings/control-section.tsx new file mode 100644 index 0000000000..9f850aa202 --- /dev/null +++ b/apps/web/core/components/project/settings/control-section.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "react-router"; +// plane imports +import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +// components +import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +// local imports +import { ArchiveRestoreProjectModal } from "../archive-restore-modal"; +import { DeleteProjectModal } from "../delete-project-modal"; + +type Props = { + projectId: string; +}; + +export const GeneralProjectSettingsControlSection = observer(function GeneralProjectSettingsControlSection( + props: Props +) { + const { projectId } = props; + // states + const [selectProject, setSelectedProject] = useState(null); + const [archiveProject, setArchiveProject] = useState(false); + // params + const { workspaceSlug } = useParams(); + // store hooks + const { currentProjectDetails } = useProject(); + // translation + const { t } = useTranslation(); + + if (!currentProjectDetails) return null; + + return ( +
+ {workspaceSlug && ( + setArchiveProject(false)} + archive + /> + )} + setSelectedProject(null)} + /> +
+ {/* Project Selector */} + setArchiveProject(true)}> + {t("archive")} + + } + /> + {/* Format Selector */} + setSelectedProject(currentProjectDetails.id ?? null)} + data-ph-element={PROJECT_TRACKER_ELEMENTS.DELETE_PROJECT_BUTTON} + > + {t("delete")} + + } + /> +
+
+ ); +}); diff --git a/apps/web/core/components/project/settings/delete-project-section.tsx b/apps/web/core/components/project/settings/delete-project-section.tsx deleted file mode 100644 index 1d81304301..0000000000 --- a/apps/web/core/components/project/settings/delete-project-section.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from "react"; -import { Disclosure, Transition } from "@headlessui/react"; -// types -import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; -import { Button } from "@plane/propel/button"; -import { ChevronRightIcon, ChevronUpIcon } from "@plane/propel/icons"; -import type { IProject } from "@plane/types"; -// ui -import { Loader } from "@plane/ui"; - -export interface IDeleteProjectSection { - projectDetails: IProject; - handleDelete: () => void; -} - -export function DeleteProjectSection(props: IDeleteProjectSection) { - const { projectDetails, handleDelete } = props; - - return ( - - {({ open }) => ( -
- - Delete project - {open ? : } - - - - -
- - When deleting a project, all of the data and resources within that project will be permanently removed - and cannot be recovered. - -
- {projectDetails ? ( -
- -
- ) : ( - - - - )} -
-
-
-
-
- )} -
- ); -} diff --git a/apps/web/core/components/project/settings/features-list.tsx b/apps/web/core/components/project/settings/features-list.tsx index fcfab32ae0..655cb7e39c 100644 --- a/apps/web/core/components/project/settings/features-list.tsx +++ b/apps/web/core/components/project/settings/features-list.tsx @@ -5,13 +5,14 @@ import { setPromiseToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; import type { IProject } from "@plane/types"; // components +import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item"; import { SettingsHeading } from "@/components/settings/heading"; // hooks import { useProject } from "@/hooks/store/use-project"; -import { useUser } from "@/hooks/store/user"; // plane web imports import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge"; import { PROJECT_FEATURES_LIST } from "@/plane-web/constants/project/settings"; +// local imports import { ProjectFeatureToggle } from "./helper"; type Props = { @@ -24,12 +25,11 @@ export const ProjectFeaturesList = observer(function ProjectFeaturesList(props: const { workspaceSlug, projectId, isAdmin } = props; // store hooks const { t } = useTranslation(); - const { data: currentUser } = useUser(); const { getProjectById, updateProject } = useProject(); // derived values const currentProjectDetails = getProjectById(projectId); - const handleSubmit = (featureKey: string, featureProperty: string) => { + const handleSubmit = (_featureKey: string, featureProperty: string) => { if (!workspaceSlug || !projectId || !currentProjectDetails) return; // making the request to update the project feature @@ -54,49 +54,45 @@ export const ProjectFeaturesList = observer(function ProjectFeaturesList(props: }); }; - if (!currentUser) return <>; - return ( -
+ <> {Object.entries(PROJECT_FEATURES_LIST).map(([featureSectionKey, feature]) => ( -
+
- {Object.entries(feature.featureList).map(([featureItemKey, featureItem]) => ( -
-
-
-
{featureItem.icon}
-
-
-

{t(featureItem.key)}

+
+ {Object.entries(feature.featureList).map(([featureItemKey, featureItem]) => ( +
+ + {t(featureItem.key)} {featureItem.isPro && ( )} -
-

- {t(`${featureItem.key}_description`)} -

-
-
- + } + description={t(`${featureItem.key}_description`)} + control={ + + } /> + {currentProjectDetails?.[featureItem.property as keyof IProject] && ( +
{featureItem.renderChildren?.(currentProjectDetails, workspaceSlug)}
+ )}
-
- {currentProjectDetails?.[featureItem.property as keyof IProject] && - featureItem.renderChildren?.(currentProjectDetails, workspaceSlug)} -
-
- ))} + ))} +
))} -
+ ); }); diff --git a/apps/web/core/components/settings/boxed-control-item.tsx b/apps/web/core/components/settings/boxed-control-item.tsx new file mode 100644 index 0000000000..6aac41f6ee --- /dev/null +++ b/apps/web/core/components/settings/boxed-control-item.tsx @@ -0,0 +1,28 @@ +// plane imports +import { cn } from "@plane/utils"; + +type Props = { + className?: string; + control?: React.ReactNode; + description?: React.ReactNode; + title: React.ReactNode; +}; + +export function SettingsBoxedControlItem(props: Props) { + const { className, control, description, title } = props; + + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {control &&
{control}
} +
+ ); +} diff --git a/apps/web/core/components/settings/content-wrapper.tsx b/apps/web/core/components/settings/content-wrapper.tsx index a15566d666..9af0f97a4c 100644 --- a/apps/web/core/components/settings/content-wrapper.tsx +++ b/apps/web/core/components/settings/content-wrapper.tsx @@ -1,22 +1,35 @@ -import type { ReactNode } from "react"; -import { observer } from "mobx-react"; +// plane imports +import { ScrollArea } from "@plane/propel/scrollarea"; import { cn } from "@plane/utils"; +// components +import { AppHeader } from "@/components/core/app-header"; -type TProps = { - children: ReactNode; - size?: "lg" | "md"; +type Props = { + children: React.ReactNode; + header?: React.ReactNode; + hugging?: boolean; }; -export const SettingsContentWrapper = observer(function SettingsContentWrapper(props: TProps) { - const { children, size = "md" } = props; + +export function SettingsContentWrapper(props: Props) { + const { children, header, hugging = false } = props; return ( -
-
{children}
+
+ {header && ( +
+ +
+ )} + +
+ {children} +
+
); -}); +} diff --git a/apps/web/core/components/settings/control-item.tsx b/apps/web/core/components/settings/control-item.tsx new file mode 100644 index 0000000000..b6074ed86d --- /dev/null +++ b/apps/web/core/components/settings/control-item.tsx @@ -0,0 +1,19 @@ +type Props = { + control: React.ReactNode; + description: string; + title: React.ReactNode; +}; + +export function SettingsControlItem(props: Props) { + const { control, description, title } = props; + + return ( +
+
+

{title}

+

{description}

+
+
{control}
+
+ ); +} diff --git a/apps/web/core/components/settings/header.tsx b/apps/web/core/components/settings/header.tsx deleted file mode 100644 index b62ad4d4b1..0000000000 --- a/apps/web/core/components/settings/header.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { observer } from "mobx-react"; -import Link from "next/link"; -import { ChevronLeftIcon } from "lucide-react"; -// plane imports -import { useTranslation } from "@plane/i18n"; -import { getButtonStyling } from "@plane/propel/button"; -import { cn } from "@plane/utils"; -// hooks -import { useWorkspace } from "@/hooks/store/use-workspace"; -import { useUserSettings } from "@/hooks/store/user"; -// local imports -import { WorkspaceLogo } from "../workspace/logo"; -import SettingsTabs from "./tabs"; - -export const SettingsHeader = observer(function SettingsHeader() { - // hooks - const { t } = useTranslation(); - const { currentWorkspace } = useWorkspace(); - const { isScrolled } = useUserSettings(); - - return ( -
- - - - {/* Breadcrumb */} - - -
-
{t("back_to_workspace")}
- {/* Last workspace */} -
- -
{currentWorkspace?.name}
-
-
- -
- {/* Description */} -
{t("settings")}
- {/* Actions */} - -
-
- ); -}); diff --git a/apps/web/core/components/settings/heading.tsx b/apps/web/core/components/settings/heading.tsx index 9f8949ee5a..7cd44034fc 100644 --- a/apps/web/core/components/settings/heading.tsx +++ b/apps/web/core/components/settings/heading.tsx @@ -1,48 +1,32 @@ -import { Button } from "@plane/propel/button"; +// plane imports import { cn } from "@plane/ui"; type Props = { - title: string | React.ReactNode; - description?: string; - appendToRight?: React.ReactNode; - showButton?: boolean; - customButton?: React.ReactNode; - button?: { - label: string; - onClick: () => void; - }; className?: string; + control?: React.ReactNode; + description?: React.ReactNode; + title?: React.ReactNode; + variant?: "h3" | "h4" | "h6"; }; -export function SettingsHeading({ - title, - description, - button, - appendToRight, - customButton, - showButton = true, - className, -}: Props) { +export function SettingsHeading({ className, control, description, title, variant = "h3" }: Props) { return ( -
+
- {typeof title === "string" ?

{title}

: title} - {description &&
{description}
} + {title && ( +

+ {title} +

+ )} + {description &&

{description}

}
- {showButton && customButton} - {button && showButton && ( - - )} - {appendToRight} + {control}
); } - -export default SettingsHeading; diff --git a/apps/web/core/components/settings/helper.ts b/apps/web/core/components/settings/helper.ts index e621fcdbd6..71fe03f2f1 100644 --- a/apps/web/core/components/settings/helper.ts +++ b/apps/web/core/components/settings/helper.ts @@ -1,5 +1,4 @@ -import { GROUPED_PROFILE_SETTINGS, GROUPED_WORKSPACE_SETTINGS } from "@plane/constants"; -import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; +import { GROUPED_WORKSPACE_SETTINGS, PROJECT_SETTINGS_FLAT_MAP } from "@plane/constants"; const hrefToLabelMap = (options: Record>) => Object.values(options) @@ -14,9 +13,7 @@ const hrefToLabelMap = (options: Record { acc[setting.href] = setting.i18n_label; return acc; @@ -39,14 +36,6 @@ export const getWorkspaceActivePath = (pathname: string) => { return workspaceHrefToLabelMap[subPath]; }; -export const getProfileActivePath = (pathname: string) => { - const parts = pathname.split("/").filter(Boolean); - const settingsIndex = parts.indexOf("settings"); - if (settingsIndex === -1) return null; - const subPath = "/" + parts.slice(settingsIndex, settingsIndex + 3).join("/"); - return profiletHrefToLabelMap[subPath]; -}; - export const getProjectActivePath = (pathname: string) => { const parts = pathname.split("/").filter(Boolean); const settingsIndex = parts.indexOf("settings"); diff --git a/apps/web/core/components/settings/mobile/index.ts b/apps/web/core/components/settings/mobile/index.ts deleted file mode 100644 index 870d3745cd..0000000000 --- a/apps/web/core/components/settings/mobile/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./nav"; diff --git a/apps/web/core/components/settings/mobile/nav.tsx b/apps/web/core/components/settings/mobile/nav.tsx index ea5871eca3..ac8e2fa2a5 100644 --- a/apps/web/core/components/settings/mobile/nav.tsx +++ b/apps/web/core/components/settings/mobile/nav.tsx @@ -5,9 +5,10 @@ import { useOutsideClickDetector } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; import { ChevronRightIcon } from "@plane/propel/icons"; import { useUserSettings } from "@/hooks/store/user"; +import { IconButton } from "@plane/propel/icon-button"; type Props = { - hamburgerContent: React.ComponentType<{ isMobile: boolean }>; + hamburgerContent: React.ComponentType<{ className?: string; isMobile?: boolean }>; activePath: string; }; @@ -24,23 +25,19 @@ export const SettingsMobileNav = observer(function SettingsMobileNav(props: Prop }); return ( -
-
-
- {!sidebarCollapsed && } - -
- {/* path */} -
- - {t(activePath)} -
+
+
+ {!sidebarCollapsed && ( +
+ +
+ )} + toggleSidebar()} /> +
+ {/* path */} +
+ + {t(activePath)}
); diff --git a/apps/web/core/components/settings/page-header.tsx b/apps/web/core/components/settings/page-header.tsx new file mode 100644 index 0000000000..56bb7dd6ca --- /dev/null +++ b/apps/web/core/components/settings/page-header.tsx @@ -0,0 +1,17 @@ +import { Header } from "@plane/ui"; + +type Props = { + leftItem?: React.ReactNode; + rightItem?: React.ReactNode; +}; + +export function SettingsPageHeader(props: Props) { + const { leftItem, rightItem } = props; + + return ( +
+ {leftItem && {leftItem}} + {rightItem && {rightItem}} +
+ ); +} diff --git a/apps/web/core/components/settings/sidebar/index.ts b/apps/web/core/components/settings/profile/content/index.ts similarity index 100% rename from apps/web/core/components/settings/sidebar/index.ts rename to apps/web/core/components/settings/profile/content/index.ts diff --git a/apps/web/core/components/settings/profile/content/pages/activity/activity-list.tsx b/apps/web/core/components/settings/profile/content/pages/activity/activity-list.tsx new file mode 100644 index 0000000000..43798f71ce --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/activity/activity-list.tsx @@ -0,0 +1,188 @@ +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import useSWR from "swr"; +// icons +import { History, MessageSquare } from "lucide-react"; +import { calculateTimeAgo, getFileURL } from "@plane/utils"; +// hooks +import { ActivityIcon, ActivityMessage } from "@/components/core/activity"; +import { RichTextEditor } from "@/components/editor/rich-text"; +import { ActivitySettingsLoader } from "@/components/ui/loader/settings/activity"; +// constants +import { USER_ACTIVITY } from "@/constants/fetch-keys"; +// hooks +import { useUserProfile } from "@/hooks/store/user/user-user-profile"; +// services +import { UserService } from "@/services/user.service"; +const userService = new UserService(); + +type Props = { + cursor: string; + perPage: number; + updateResultsCount: (count: number) => void; + updateTotalPages: (count: number) => void; + updateEmptyState: (state: boolean) => void; +}; + +export const ActivityProfileSettingsList = observer(function ProfileActivityListPage(props: Props) { + const { cursor, perPage, updateResultsCount, updateTotalPages, updateEmptyState } = props; + // store hooks + const { data: currentUser } = useUserProfile(); + + const { data: userProfileActivity } = useSWR( + USER_ACTIVITY({ + cursor, + }), + () => + userService.getUserActivity({ + cursor, + per_page: perPage, + }) + ); + + useEffect(() => { + if (!userProfileActivity) return; + + // if no results found then show empty state + if (userProfileActivity.total_results === 0) updateEmptyState(true); + + updateTotalPages(userProfileActivity.total_pages); + updateResultsCount(userProfileActivity.results.length); + }, [updateResultsCount, updateTotalPages, userProfileActivity, updateEmptyState]); + + // TODO: refactor this component + return ( + <> + {userProfileActivity ? ( +
    + {userProfileActivity.results.map((activityItem: any) => { + if (activityItem.field === "comment") + return ( +
    +
    +
    + {activityItem.field ? ( + activityItem.new_value === "restore" && + ) : activityItem.actor_detail.avatar_url && activityItem.actor_detail.avatar_url !== "" ? ( + {activityItem.actor_detail.display_name} + ) : ( +
    + {activityItem.actor_detail.display_name?.[0]} +
    + )} + + + +
    +
    +
    +
    + {activityItem.actor_detail.is_bot + ? activityItem.actor_detail.first_name + " Bot" + : activityItem.actor_detail.display_name} +
    +

    + Commented {calculateTimeAgo(activityItem.created_at)} +

    +
    +
    + +
    +
    +
    +
    + ); + + const message = ; + + if ("field" in activityItem && activityItem.field !== "updated_by") + return ( +
  • +
    +
    + <> +
    +
    +
    +
    + {activityItem.field ? ( + activityItem.new_value === "restore" ? ( + + ) : ( + + ) + ) : activityItem.actor_detail.avatar_url && + activityItem.actor_detail.avatar_url !== "" ? ( + {activityItem.actor_detail.display_name} + ) : ( +
    + {activityItem.actor_detail.display_name?.[0]} +
    + )} +
    +
    +
    +
    +
    +
    + {activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? ( + Plane + ) : activityItem.actor_detail.is_bot ? ( + {activityItem.actor_detail.first_name} Bot + ) : ( + + + {currentUser?.id === activityItem.actor_detail.id + ? "You" + : activityItem.actor_detail.display_name} + + + )}{" "} +
    + {message}{" "} + + {calculateTimeAgo(activityItem.created_at)} + +
    +
    +
    + +
    +
    +
  • + ); + })} +
+ ) : ( + + )} + + ); +}); diff --git a/apps/web/core/components/settings/profile/content/pages/activity/index.ts b/apps/web/core/components/settings/profile/content/pages/activity/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/activity/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx b/apps/web/core/components/settings/profile/content/pages/activity/root.tsx similarity index 81% rename from apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx rename to apps/web/core/components/settings/profile/content/pages/activity/root.tsx index 00b795dfdc..71c3292a32 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx +++ b/apps/web/core/components/settings/profile/content/pages/activity/root.tsx @@ -1,23 +1,22 @@ import { useState } from "react"; +import { ChevronDown } from "lucide-react"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; +// plane imports import { useTranslation } from "@plane/i18n"; -// ui import { Button } from "@plane/propel/button"; // assets import darkActivityAsset from "@/app/assets/empty-state/profile/activity-dark.webp?url"; import lightActivityAsset from "@/app/assets/empty-state/profile/activity-light.webp?url"; // components -import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; -import { ProfileActivityListPage } from "@/components/profile/activity/profile-activity-list"; -// hooks -import { SettingsHeading } from "@/components/settings/heading"; -import { ChevronDown } from "lucide-react"; +import { ProfileSettingsHeading } from "@/components/settings/profile/heading"; +// local imports +import { ActivityProfileSettingsList } from "./activity-list"; const PER_PAGE = 100; -function ProfileActivityPage() { +export const ActivityProfileSettings = observer(function ActivityProfileSettings() { // states const [pageCount, setPageCount] = useState(1); const [totalPages, setTotalPages] = useState(0); @@ -41,7 +40,7 @@ function ProfileActivityPage() { const activityPages: React.ReactNode[] = []; for (let i = 0; i < pageCount; i++) activityPages.push( - - + @@ -72,13 +71,12 @@ function ProfileActivityPage() { } return ( - <> - - + -
{activityPages}
+
{activityPages}
{isLoadMoreVisible && (
)} - +
); -} - -export default observer(ProfileActivityPage); +}); diff --git a/apps/web/core/components/settings/profile/content/pages/api-tokens.tsx b/apps/web/core/components/settings/profile/content/pages/api-tokens.tsx new file mode 100644 index 0000000000..630b0ab5ff --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/api-tokens.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { EmptyStateCompact } from "@plane/propel/empty-state"; +import { APITokenService } from "@plane/services"; +// components +import { CreateApiTokenModal } from "@/components/api-token/modal/create-token-modal"; +import { ApiTokenListItem } from "@/components/api-token/token-list-item"; +import { ProfileSettingsHeading } from "@/components/settings/profile/heading"; +import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token"; +// constants +import { API_TOKENS_LIST } from "@/constants/fetch-keys"; + +const apiTokenService = new APITokenService(); + +export const APITokensProfileSettings = observer(function APITokensProfileSettings() { + // states + const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false); + // store hooks + const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list()); + // translation + const { t } = useTranslation(); + + if (!tokens) { + return ; + } + + return ( +
+ setIsCreateTokenModalOpen(false)} /> + setIsCreateTokenModalOpen(true)}> + {t("workspace_settings.settings.api_tokens.add_token")} + + } + /> +
+ {tokens.length > 0 ? ( + <> +
+ {tokens.map((token) => ( + + ))} +
+ + ) : ( + { + setIsCreateTokenModalOpen(true); + }, + }, + ]} + align="start" + rootClassName="py-20" + /> + )} +
+
+ ); +}); diff --git a/apps/web/core/components/profile/form.tsx b/apps/web/core/components/settings/profile/content/pages/general/form.tsx similarity index 88% rename from apps/web/core/components/profile/form.tsx rename to apps/web/core/components/settings/profile/content/pages/general/form.tsx index ec9d52bb29..8d47033da3 100644 --- a/apps/web/core/components/profile/form.tsx +++ b/apps/web/core/components/settings/profile/content/pages/general/form.tsx @@ -2,11 +2,9 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; import { CircleUserRound } from "lucide-react"; -import { Disclosure, Transition } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; -import { ChevronDownIcon } from "@plane/propel/icons"; import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast"; import { EFileAssetType } from "@plane/types"; import type { IUser, TUserProfile } from "@plane/types"; @@ -18,6 +16,7 @@ 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"; import { CoverImage } from "@/components/common/cover-image"; +import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item"; // helpers import { handleCoverImageChange } from "@/helpers/cover-image.helper"; // hooks @@ -38,12 +37,12 @@ type TUserProfileForm = { user_timezone: string; }; -export type TProfileFormProps = { +type Props = { user: IUser; profile: TUserProfile; }; -export const ProfileForm = observer(function ProfileForm(props: TProfileFormProps) { +export const GeneralProfileSettingsForm = observer(function GeneralProfileSettingsForm(props: Props) { const { user, profile } = props; // states const [isLoading, setIsLoading] = useState(false); @@ -189,7 +188,7 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp )} />
-
+
-
-
- -
+
+
- - {({ open }) => ( - <> - - {t("deactivate_account")} - - - - -
- {t("deactivate_account_description")} -
- -
-
-
-
- - )} -
+
+ setDeactivateAccountModal(true)}> + {t("deactivate_account")} + + } + /> +
); }); diff --git a/apps/web/core/components/settings/profile/content/pages/general/index.ts b/apps/web/core/components/settings/profile/content/pages/general/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/general/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx b/apps/web/core/components/settings/profile/content/pages/general/root.tsx similarity index 61% rename from apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx rename to apps/web/core/components/settings/profile/content/pages/general/root.tsx index 7e52686778..47673520e7 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx +++ b/apps/web/core/components/settings/profile/content/pages/general/root.tsx @@ -2,22 +2,22 @@ import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; // components import { PageHead } from "@/components/core/page-title"; -import { ProfileForm } from "@/components/profile/form"; // hooks import { useUser } from "@/hooks/store/user"; +// local imports +import { GeneralProfileSettingsForm } from "./form"; -function ProfileSettingsPage() { +export const GeneralProfileSettings = observer(function GeneralProfileSettings() { const { t } = useTranslation(); // store hooks const { data: currentUser, userProfile } = useUser(); - if (!currentUser) return <>; + if (!currentUser) return null; + return ( <> - + ); -} - -export default observer(ProfileSettingsPage); +}); diff --git a/apps/web/core/components/settings/profile/content/pages/index.ts b/apps/web/core/components/settings/profile/content/pages/index.ts new file mode 100644 index 0000000000..030086c2e0 --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/index.ts @@ -0,0 +1,12 @@ +import { lazy } from "react"; +// plane imports +import type { TProfileSettingsTabs } from "@plane/types"; + +export const PROFILE_SETTINGS_PAGES_MAP: Record> = { + general: lazy(() => import("./general").then((m) => ({ default: m.GeneralProfileSettings }))), + preferences: lazy(() => import("./preferences").then((m) => ({ default: m.PreferencesProfileSettings }))), + notifications: lazy(() => import("./notifications").then((m) => ({ default: m.NotificationsProfileSettings }))), + security: lazy(() => import("./security").then((m) => ({ default: m.SecurityProfileSettings }))), + activity: lazy(() => import("./activity").then((m) => ({ default: m.ActivityProfileSettings }))), + "api-tokens": lazy(() => import("./api-tokens").then((m) => ({ default: m.APITokensProfileSettings }))), +}; diff --git a/apps/web/core/components/settings/profile/content/pages/notifications/email-notification-form.tsx b/apps/web/core/components/settings/profile/content/pages/notifications/email-notification-form.tsx new file mode 100644 index 0000000000..d4e46a6330 --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/notifications/email-notification-form.tsx @@ -0,0 +1,161 @@ +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IUserEmailNotificationSettings } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +// components +import { SettingsControlItem } from "@/components/settings/control-item"; +// services +import { UserService } from "@/services/user.service"; + +type Props = { + data: IUserEmailNotificationSettings; +}; + +// services +const userService = new UserService(); + +export const NotificationsProfileSettingsForm = observer(function NotificationsProfileSettingsForm(props: Props) { + const { data } = props; + // translation + const { t } = useTranslation(); + // form data + const { control, reset } = useForm({ + defaultValues: { + ...data, + }, + }); + + const handleSettingChange = async (key: keyof IUserEmailNotificationSettings, value: boolean) => { + try { + await userService.updateCurrentUserEmailNotificationSettings({ + [key]: value, + }); + setToast({ + title: t("success"), + type: TOAST_TYPE.SUCCESS, + message: t("email_notification_setting_updated_successfully"), + }); + } catch (_error) { + setToast({ + title: t("error"), + type: TOAST_TYPE.ERROR, + message: t("failed_to_update_email_notification_setting"), + }); + } + }; + + useEffect(() => { + reset(data); + }, [reset, data]); + + return ( +
+ ( + { + onChange(newValue); + handleSettingChange("property_change", newValue); + }} + size="sm" + /> + )} + /> + } + /> + ( + { + onChange(newValue); + handleSettingChange("state_change", newValue); + }} + size="sm" + /> + )} + /> + } + /> +
+ ( + { + onChange(newValue); + handleSettingChange("issue_completed", newValue); + }} + size="sm" + /> + )} + /> + } + /> +
+ ( + { + onChange(newValue); + handleSettingChange("comment", newValue); + }} + size="sm" + /> + )} + /> + } + /> + ( + { + onChange(newValue); + handleSettingChange("mention", newValue); + }} + size="sm" + /> + )} + /> + } + /> +
+ ); +}); diff --git a/apps/web/core/components/settings/profile/content/pages/notifications/index.ts b/apps/web/core/components/settings/profile/content/pages/notifications/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/notifications/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx b/apps/web/core/components/settings/profile/content/pages/notifications/root.tsx similarity index 60% rename from apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx rename to apps/web/core/components/settings/profile/content/pages/notifications/root.tsx index c4d05e1f88..7aabc3cb9e 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx +++ b/apps/web/core/components/settings/profile/content/pages/notifications/root.tsx @@ -1,17 +1,18 @@ import useSWR from "swr"; +import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; // components -import { PageHead } from "@/components/core/page-title"; -import { EmailNotificationForm } from "@/components/profile/notification/email-notification-form"; -import { SettingsHeading } from "@/components/settings/heading"; +import { ProfileSettingsHeading } from "@/components/settings/profile/heading"; import { EmailSettingsLoader } from "@/components/ui/loader/settings/email"; // services import { UserService } from "@/services/user.service"; +// local imports +import { NotificationsProfileSettingsForm } from "./email-notification-form"; const userService = new UserService(); -export default function ProfileNotificationPage() { +export const NotificationsProfileSettings = observer(function NotificationsProfileSettings() { const { t } = useTranslation(); // fetching user email notification settings const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => @@ -23,14 +24,14 @@ export default function ProfileNotificationPage() { } return ( - <> - - - + - - +
+ +
+
); -} +}); diff --git a/apps/web/core/components/settings/profile/content/pages/preferences/default-list.tsx b/apps/web/core/components/settings/profile/content/pages/preferences/default-list.tsx new file mode 100644 index 0000000000..0952adca93 --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/preferences/default-list.tsx @@ -0,0 +1,17 @@ +import { observer } from "mobx-react"; +// components +import { ThemeSwitcher } from "ce/components/preferences/theme-switcher"; + +export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() { + return ( +
+ +
+ ); +}); diff --git a/apps/web/core/components/settings/profile/content/pages/preferences/index.ts b/apps/web/core/components/settings/profile/content/pages/preferences/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/preferences/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/settings/profile/content/pages/preferences/language-and-timezone-list.tsx b/apps/web/core/components/settings/profile/content/pages/preferences/language-and-timezone-list.tsx new file mode 100644 index 0000000000..1afcff638d --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/preferences/language-and-timezone-list.tsx @@ -0,0 +1,102 @@ +import { observer } from "mobx-react"; +// plane imports +import { SUPPORTED_LANGUAGES, useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { CustomSelect } from "@plane/ui"; +// components +import { TimezoneSelect } from "@/components/global"; +import { StartOfWeekPreference } from "@/components/profile/start-of-week-preference"; +import { SettingsControlItem } from "@/components/settings/control-item"; +// hooks +import { useUser, useUserProfile } from "@/hooks/store/user"; + +export const ProfileSettingsLanguageAndTimezonePreferencesList = observer( + function ProfileSettingsLanguageAndTimezonePreferencesList() { + // store hooks + const { + data: user, + updateCurrentUser, + userProfile: { data: profile }, + } = useUser(); + const { updateUserProfile } = useUserProfile(); + // translation + const { t } = useTranslation(); + + const handleTimezoneChange = async (value: string) => { + try { + await updateCurrentUser({ user_timezone: value }); + setToast({ + title: "Success!", + message: "Timezone updated successfully", + type: TOAST_TYPE.SUCCESS, + }); + } catch (_error) { + setToast({ + title: "Error!", + message: "Failed to update timezone", + type: TOAST_TYPE.ERROR, + }); + } + }; + + const handleLanguageChange = async (value: string) => { + try { + await updateUserProfile({ language: value }); + setToast({ + title: "Success!", + message: "Language updated successfully", + type: TOAST_TYPE.SUCCESS, + }); + } catch (_error) { + setToast({ + title: "Error!", + message: "Failed to update language", + type: TOAST_TYPE.ERROR, + }); + } + }; + + const getLanguageLabel = (value: string) => { + const selectedLanguage = SUPPORTED_LANGUAGES.find((l) => l.value === value); + if (!selectedLanguage) return value; + return selectedLanguage.label; + }; + + return ( +
+ } + /> + + {SUPPORTED_LANGUAGES.map((item) => ( + + {item.label} + + ))} + + } + /> + +
+ ); + } +); diff --git a/apps/web/core/components/settings/profile/content/pages/preferences/root.tsx b/apps/web/core/components/settings/profile/content/pages/preferences/root.tsx new file mode 100644 index 0000000000..38e53fcabe --- /dev/null +++ b/apps/web/core/components/settings/profile/content/pages/preferences/root.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { ProfileSettingsHeading } from "@/components/settings/profile/heading"; +// hooks +import { useUserProfile } from "@/hooks/store/user"; +// local imports +import { ProfileSettingsDefaultPreferencesList } from "./default-list"; +import { ProfileSettingsLanguageAndTimezonePreferencesList } from "./language-and-timezone-list"; + +export const PreferencesProfileSettings = observer(function PreferencesProfileSettings() { + const { t } = useTranslation(); + // hooks + const { data: userProfile } = useUserProfile(); + + if (!userProfile) return null; + + return ( +
+ +
+
+ +
+
+
{t("language_and_time")}
+ +
+
+
+ ); +}); diff --git a/apps/web/app/(all)/profile/security/page.tsx b/apps/web/core/components/settings/profile/content/pages/security.tsx similarity index 73% rename from apps/web/app/(all)/profile/security/page.tsx rename to apps/web/core/components/settings/profile/content/pages/security.tsx index aad3001fa6..fa6916b12b 100644 --- a/apps/web/app/(all)/profile/security/page.tsx +++ b/apps/web/core/components/settings/profile/content/pages/security.tsx @@ -8,11 +8,9 @@ import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Input, PasswordStrengthIndicator } from "@plane/ui"; -// components import { getPasswordStrength } from "@plane/utils"; -import { PageHead } from "@/components/core/page-title"; -import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; -import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; +// components +import { ProfileSettingsHeading } from "@/components/settings/profile/heading"; // helpers import { authErrorHandler } from "@/helpers/authentication.helper"; import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; @@ -41,7 +39,7 @@ const defaultShowPassword = { confirmPassword: false, }; -function SecurityPage() { +export const SecurityProfileSettings = observer(function SecurityProfileSettings() { // store const { data: currentUser, changePassword } = useUser(); // states @@ -89,9 +87,12 @@ function SecurityPage() { message: t("auth.common.password.toast.change_password.success.message"), }); } catch (error: unknown) { - const err = error as Error & { error_code?: string }; - const code = err.error_code?.toString(); - const errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined; + let errorInfo = undefined; + if (error instanceof Error) { + const code = "error_code" in error ? error.error_code?.toString() : undefined; + errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined; + } + setToast({ type: TOAST_TYPE.ERROR, title: errorInfo?.title ?? t("auth.common.password.toast.error.title"), @@ -117,52 +118,51 @@ function SecurityPage() { const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; return ( - <> - - - -
-
- {oldPasswordRequired && ( -
-

{t("auth.common.password.current_password.label")}

-
- ( - - )} - /> - {showPassword?.oldPassword ? ( - handleShowPassword("oldPassword")} - /> - ) : ( - handleShowPassword("oldPassword")} +
+ + +
+ {oldPasswordRequired && ( +
+

{t("auth.common.password.current_password.label")}

+
+ ( + )} -
- {errors.old_password && ( - {errors.old_password.message} + /> + {showPassword?.oldPassword ? ( + handleShowPassword("oldPassword")} + /> + ) : ( + handleShowPassword("oldPassword")} + /> )}
- )} -
+ {errors.old_password && ( + {errors.old_password.message} + )} +
+ )} +
+

{t("auth.common.password.new_password.label")}

)}
-
+

{t("auth.common.password.confirm_password.label")}

- -
-
- - - +
+ +
); -} - -export default observer(SecurityPage); +}); diff --git a/apps/web/core/components/settings/profile/content/root.tsx b/apps/web/core/components/settings/profile/content/root.tsx new file mode 100644 index 0000000000..0b4e721c2b --- /dev/null +++ b/apps/web/core/components/settings/profile/content/root.tsx @@ -0,0 +1,32 @@ +import { Suspense } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { ScrollArea } from "@plane/propel/scrollarea"; +import type { TProfileSettingsTabs } from "@plane/types"; +import { cn } from "@plane/utils"; +// local imports +import { PROFILE_SETTINGS_PAGES_MAP } from "./pages"; + +type Props = { + activeTab: TProfileSettingsTabs; + className?: string; +}; + +export const ProfileSettingsContent = observer(function ProfileSettingsContent(props: Props) { + const { activeTab, className } = props; + const PageComponent = PROFILE_SETTINGS_PAGES_MAP[activeTab]; + + return ( + + + + + + ); +}); diff --git a/apps/web/core/components/settings/profile/heading.tsx b/apps/web/core/components/settings/profile/heading.tsx new file mode 100644 index 0000000000..2a1f6433a0 --- /dev/null +++ b/apps/web/core/components/settings/profile/heading.tsx @@ -0,0 +1,21 @@ +// plane imports +import { cn } from "@plane/ui"; + +type Props = { + className?: string; + control?: React.ReactNode; + description?: React.ReactNode; + title?: React.ReactNode; +}; + +export function ProfileSettingsHeading({ className, control, description, title }: Props) { + return ( +
+
+ {title &&
{title}
} + {description &&

{description}

} +
+ {control} +
+ ); +} diff --git a/apps/web/core/components/settings/profile/modal.tsx b/apps/web/core/components/settings/profile/modal.tsx new file mode 100644 index 0000000000..d1ffbd33d9 --- /dev/null +++ b/apps/web/core/components/settings/profile/modal.tsx @@ -0,0 +1,53 @@ +import { useCallback } from "react"; +import { X } from "lucide-react"; +import { observer } from "mobx-react"; +// plane imports +import { IconButton } from "@plane/propel/icon-button"; +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; +// hooks +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +// local imports +import { ProfileSettingsContent } from "./content"; +import { ProfileSettingsSidebarRoot } from "./sidebar"; + +export const ProfileSettingsModal = observer(function ProfileSettingsModal() { + // store hooks + const { profileSettingsModal, toggleProfileSettingsModal } = useCommandPalette(); + // derived values + const activeTab = profileSettingsModal.activeTab ?? "general"; + + const handleClose = useCallback(() => { + toggleProfileSettingsModal({ + isOpen: false, + }); + setTimeout(() => { + toggleProfileSettingsModal({ + activeTab: null, + }); + }, 300); + }, [toggleProfileSettingsModal]); + + return ( + +
+
+ toggleProfileSettingsModal({ activeTab: tab })} + /> + +
+
+ +
+
+
+ ); +}); diff --git a/apps/web/core/components/settings/profile/sidebar/header.tsx b/apps/web/core/components/settings/profile/sidebar/header.tsx new file mode 100644 index 0000000000..1bd4f1b1c7 --- /dev/null +++ b/apps/web/core/components/settings/profile/sidebar/header.tsx @@ -0,0 +1,31 @@ +import { observer } from "mobx-react"; +// plane imports +import { Avatar } from "@plane/ui"; +// hooks +import { useUser } from "@/hooks/store/user"; +import { getFileURL } from "@plane/utils"; + +export const ProfileSettingsSidebarHeader = observer(function ProfileSettingsSidebarHeader() { + // store hooks + const { data: currentUser } = useUser(); + + return ( +
+
+ +
+
+

+ {currentUser?.first_name} {currentUser?.last_name} +

+

{currentUser?.email}

+
+
+ ); +}); diff --git a/apps/web/core/components/settings/profile/sidebar/index.ts b/apps/web/core/components/settings/profile/sidebar/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/apps/web/core/components/settings/profile/sidebar/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/settings/profile/sidebar/item-categories.tsx b/apps/web/core/components/settings/profile/sidebar/item-categories.tsx new file mode 100644 index 0000000000..b204e4110d --- /dev/null +++ b/apps/web/core/components/settings/profile/sidebar/item-categories.tsx @@ -0,0 +1,66 @@ +import type React from "react"; +import type { LucideIcon } from "lucide-react"; +import { Activity, Bell, CircleUser, KeyRound, LockIcon, Settings2 } from "lucide-react"; +import { observer } from "mobx-react"; +import { useParams } from "react-router"; +// plane imports +import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { ISvgIcons } from "@plane/propel/icons"; +import type { TProfileSettingsTabs } from "@plane/types"; +// local imports +import { SettingsSidebarItem } from "../../sidebar/item"; +import { ProfileSettingsSidebarWorkspaceOptions } from "./workspace-options"; + +const ICONS: Record> = { + general: CircleUser, + security: LockIcon, + activity: Activity, + preferences: Settings2, + notifications: Bell, + "api-tokens": KeyRound, +}; + +type Props = { + activeTab: TProfileSettingsTabs; + updateActiveTab: (tab: TProfileSettingsTabs) => void; +}; + +export const ProfileSettingsSidebarItemCategories = observer(function ProfileSettingsSidebarItemCategories( + props: Props +) { + const { activeTab, updateActiveTab } = props; + // params + const { profileTabId } = useParams(); + // translation + const { t } = useTranslation(); + + return ( +
+ {PROFILE_SETTINGS_CATEGORIES.map((category) => { + const categoryItems = GROUPED_PROFILE_SETTINGS[category]; + + if (categoryItems.length === 0) return null; + + return ( +
+
{t(category)}
+
+ {categoryItems.map((item) => ( + updateActiveTab(item.key)} + isActive={activeTab === item.key} + icon={ICONS[item.key]} + label={t(item.i18n_label)} + /> + ))} +
+
+ ); + })} + {profileTabId && } +
+ ); +}); diff --git a/apps/web/core/components/settings/profile/sidebar/root.tsx b/apps/web/core/components/settings/profile/sidebar/root.tsx new file mode 100644 index 0000000000..703218e04f --- /dev/null +++ b/apps/web/core/components/settings/profile/sidebar/root.tsx @@ -0,0 +1,29 @@ +// plane imports +import { ScrollArea } from "@plane/propel/scrollarea"; +import type { TProfileSettingsTabs } from "@plane/types"; +import { cn } from "@plane/utils"; +// local imports +import { ProfileSettingsSidebarHeader } from "./header"; +import { ProfileSettingsSidebarItemCategories } from "./item-categories"; + +type Props = { + activeTab: TProfileSettingsTabs; + className?: string; + updateActiveTab: (tab: TProfileSettingsTabs) => void; +}; + +export function ProfileSettingsSidebarRoot(props: Props) { + const { activeTab, className, updateActiveTab } = props; + + return ( + + + + + ); +} diff --git a/apps/web/core/components/settings/profile/sidebar/workspace-options.tsx b/apps/web/core/components/settings/profile/sidebar/workspace-options.tsx new file mode 100644 index 0000000000..a695437ad3 --- /dev/null +++ b/apps/web/core/components/settings/profile/sidebar/workspace-options.tsx @@ -0,0 +1,50 @@ +import { CirclePlus, Mails } from "lucide-react"; +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { SettingsSidebarItem } from "@/components/settings/sidebar/item"; +import { WorkspaceLogo } from "@/components/workspace/logo"; +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; + +export const ProfileSettingsSidebarWorkspaceOptions = observer(function ProfileSettingsSidebarWorkspaceOptions() { + // store hooks + const { workspaces } = useWorkspace(); + // translation + const { t } = useTranslation(); + + return ( +
+
{t("workspace")}
+
+ {Object.values(workspaces).map((workspace) => ( + } + label={workspace.name} + isActive={false} + /> + ))} +
+ + +
+
+
+ ); +}); diff --git a/apps/web/core/components/settings/project/content/feature-control-item.tsx b/apps/web/core/components/settings/project/content/feature-control-item.tsx new file mode 100644 index 0000000000..227e8cd52b --- /dev/null +++ b/apps/web/core/components/settings/project/content/feature-control-item.tsx @@ -0,0 +1,60 @@ +import { observer } from "mobx-react"; +// plane imports +import { setPromiseToast } from "@plane/propel/toast"; +import type { IProject } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +// components +import { SettingsBoxedControlItem } from "@/components/settings/boxed-control-item"; +// hooks +import { useProject } from "@/hooks/store/use-project"; + +type Props = { + description?: React.ReactNode; + disabled?: boolean; + projectId: string; + featureProperty: keyof IProject; + title: React.ReactNode; + value: boolean; + workspaceSlug: string; +}; + +export const ProjectSettingsFeatureControlItem = observer(function ProjectSettingsFeatureControlItem(props: Props) { + const { description, disabled, featureProperty, projectId, title, value, workspaceSlug } = props; + // store hooks + const { getProjectById, updateProject } = useProject(); + // derived values + const currentProjectDetails = getProjectById(projectId); + + const handleSubmit = () => { + if (!workspaceSlug || !projectId || !currentProjectDetails) return; + + // making the request to update the project feature + const settingsPayload = { + [featureProperty]: !currentProjectDetails?.[featureProperty], + }; + const updateProjectPromise = updateProject(workspaceSlug, projectId, settingsPayload); + + setPromiseToast(updateProjectPromise, { + loading: "Updating project feature...", + success: { + title: "Success!", + message: () => "Project feature updated successfully.", + }, + error: { + title: "Error!", + message: () => "Something went wrong while updating project feature. Please try again.", + }, + }); + void updateProjectPromise.then(() => { + return undefined; + }); + }; + + return ( + } + /> + ); +}); diff --git a/apps/web/core/components/settings/project/sidebar/header.tsx b/apps/web/core/components/settings/project/sidebar/header.tsx new file mode 100644 index 0000000000..aa255908f5 --- /dev/null +++ b/apps/web/core/components/settings/project/sidebar/header.tsx @@ -0,0 +1,58 @@ +import { ArrowLeft } from "lucide-react"; +import { observer } from "mobx-react"; +// plane imports +import { ROLE_DETAILS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Logo } from "@plane/propel/emoji-icon-picker"; +import { IconButton } from "@plane/propel/icon-button"; +// hooks +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useProject } from "@/hooks/store/use-project"; +import { useWorkspace } from "@/hooks/store/use-workspace"; + +type Props = { + projectId: string; +}; + +export const ProjectSettingsSidebarHeader = observer(function ProjectSettingsSidebarHeader(props: Props) { + const { projectId } = props; + // router + const router = useAppRouter(); + // store hooks + const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); + const { currentWorkspace } = useWorkspace(); + const { getPartialProjectById } = useProject(); + // derived values + const projectDetails = getPartialProjectById(projectId); + const currentProjectRole = currentWorkspace?.slug + ? getProjectRoleByWorkspaceSlugAndProjectId(currentWorkspace.slug, projectId) + : undefined; + // translation + const { t } = useTranslation(); + + if (!currentProjectRole) return null; + + return ( +
+
+ router.push(`/${currentWorkspace?.slug}/projects/${projectId}/issues/`)} + /> +

Project settings

+
+
+
+ +
+
+

{projectDetails?.name}

+

{t(ROLE_DETAILS[currentProjectRole].i18n_title)}

+
+
+
+ ); +}); diff --git a/apps/web/core/components/settings/project/sidebar/item-categories.tsx b/apps/web/core/components/settings/project/sidebar/item-categories.tsx new file mode 100644 index 0000000000..d368a27928 --- /dev/null +++ b/apps/web/core/components/settings/project/sidebar/item-categories.tsx @@ -0,0 +1,67 @@ +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +import { useParams } from "react-router"; +// plane imports +import { EUserPermissionsLevel, GROUPED_PROJECT_SETTINGS, PROJECT_SETTINGS_CATEGORIES } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// components +import { SettingsSidebarItem } from "@/components/settings/sidebar/item"; +// hooks +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import { PROJECT_SETTINGS_ICONS } from "./item-icon"; + +type Props = { + projectId: string; +}; + +export const ProjectSettingsSidebarItemCategories = observer(function ProjectSettingsSidebarItemCategories( + props: Props +) { + const { projectId } = props; + // params + const { workspaceSlug } = useParams(); + const pathname = usePathname(); + // store hooks + const { allowPermissions } = useUserPermissions(); + // translation + const { t } = useTranslation(); + + return ( +
+ {PROJECT_SETTINGS_CATEGORIES.map((category) => { + const categoryItems = GROUPED_PROJECT_SETTINGS[category]; + const accessibleItems = categoryItems.filter((item) => + allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) + ); + + if (accessibleItems.length === 0) return null; + + return ( +
+
{t(category)}
+
+ {accessibleItems.map((item) => { + const isItemActive = + item.href === "" + ? pathname === `/${workspaceSlug}/settings/projects/${projectId}${item.href}/` + : new RegExp(`^/${workspaceSlug}/settings/projects/${projectId}${item.href}/`).test(pathname); + + return ( + + ); + })} +
+
+ ); + })} +
+ ); +}); diff --git a/apps/web/core/components/settings/project/sidebar/item-icon.tsx b/apps/web/core/components/settings/project/sidebar/item-icon.tsx new file mode 100644 index 0000000000..6988776b3c --- /dev/null +++ b/apps/web/core/components/settings/project/sidebar/item-icon.tsx @@ -0,0 +1,31 @@ +import type { LucideIcon } from "lucide-react"; +import { Users, Zap } from "lucide-react"; +// plane imports +import type { ISvgIcons } from "@plane/propel/icons"; +import { + CycleIcon, + EstimatePropertyIcon, + IntakeIcon, + LabelPropertyIcon, + ModuleIcon, + PageIcon, + StatePropertyIcon, + ViewsIcon, +} from "@plane/propel/icons"; +import type { TProjectSettingsTabs } from "@plane/types"; +// components +import { SettingIcon } from "@/components/icons/attachment"; + +export const PROJECT_SETTINGS_ICONS: Record> = { + general: SettingIcon, + members: Users, + features_cycles: CycleIcon, + features_modules: ModuleIcon, + features_views: ViewsIcon, + features_pages: PageIcon, + features_intake: IntakeIcon, + states: StatePropertyIcon, + labels: LabelPropertyIcon, + estimates: EstimatePropertyIcon, + automations: Zap, +}; diff --git a/apps/web/core/components/settings/project/sidebar/nav-item-children.tsx b/apps/web/core/components/settings/project/sidebar/nav-item-children.tsx deleted file mode 100644 index 5c79d9241d..0000000000 --- a/apps/web/core/components/settings/project/sidebar/nav-item-children.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { range } from "lodash-es"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { usePathname, useParams } from "next/navigation"; -import { EUserPermissionsLevel } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { Loader } from "@plane/ui"; -import { cn } from "@plane/utils"; -import { useProject } from "@/hooks/store/use-project"; -import { useUserPermissions, useUserSettings } from "@/hooks/store/user"; -import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; -import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings"; - -export const NavItemChildren = observer(function NavItemChildren(props: { projectId: string }) { - const { projectId } = props; - const { workspaceSlug } = useParams(); - const pathname = usePathname(); - // mobx store - const { getProjectById } = useProject(); - const { allowPermissions } = useUserPermissions(); - const { t } = useTranslation(); - const { toggleSidebar } = useUserSettings(); - - // derived values - const currentProject = getProjectById(projectId); - - if (!currentProject) { - return ( -
-
- - {range(8).map((index) => ( - - ))} - -
-
- ); - } - - return ( -
-
-
- {PROJECT_SETTINGS_LINKS.map((link) => { - const isActive = link.highlight(pathname, `/${workspaceSlug}/settings/projects/${projectId}`); - return ( - allowPermissions( - link.access, - EUserPermissionsLevel.PROJECT, - workspaceSlug?.toString() ?? "", - projectId?.toString() ?? "" - ) && ( - toggleSidebar(true)} - > -
- {t(getProjectSettingsPageLabelI18nKey(link.key, link.i18n_label))} -
- - ) - ); - })} -
-
-
- ); -}); diff --git a/apps/web/core/components/settings/project/sidebar/root.tsx b/apps/web/core/components/settings/project/sidebar/root.tsx index 4a51f55baa..2764e96286 100644 --- a/apps/web/core/components/settings/project/sidebar/root.tsx +++ b/apps/web/core/components/settings/project/sidebar/root.tsx @@ -1,52 +1,26 @@ -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // plane imports -import { PROJECT_SETTINGS_CATEGORIES, PROJECT_SETTINGS_CATEGORY } from "@plane/constants"; -import { Logo } from "@plane/propel/emoji-icon-picker"; -import { getUserRole } from "@plane/utils"; -// components -// hooks -import { useProject } from "@/hooks/store/use-project"; +import { ScrollArea } from "@plane/propel/scrollarea"; // local imports -import { SettingsSidebar } from "../../sidebar"; -import { NavItemChildren } from "./nav-item-children"; +import { ProjectSettingsSidebarHeader } from "./header"; +import { ProjectSettingsSidebarItemCategories } from "./item-categories"; -type TProjectSettingsSidebarProps = { - isMobile?: boolean; +type Props = { + projectId: string; }; -export const ProjectSettingsSidebar = observer(function ProjectSettingsSidebar(props: TProjectSettingsSidebarProps) { - const { isMobile = false } = props; - const { workspaceSlug } = useParams(); - // store hooks - const { joinedProjectIds, projectMap } = useProject(); - - const groupedProject = joinedProjectIds.map((projectId) => ({ - key: projectId, - i18n_label: projectMap[projectId].name, - href: `/settings/projects/${projectId}`, - icon: , - })); +export function ProjectSettingsSidebarRoot(props: Props) { + const { projectId } = props; return ( - { - const role = projectMap[key].member_role; - return ( -
- {role ? getUserRole(role)?.toLowerCase() : "Guest"} -
- ); - }} - shouldRender - renderChildren={(key: string) => } - /> + + + + ); -}); +} diff --git a/apps/web/core/components/settings/sidebar/header.tsx b/apps/web/core/components/settings/sidebar/header.tsx deleted file mode 100644 index ca5881f6fa..0000000000 --- a/apps/web/core/components/settings/sidebar/header.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { observer } from "mobx-react"; -// plane imports -import { getUserRole } from "@plane/utils"; -// components -import { WorkspaceLogo } from "@/components/workspace/logo"; -// hooks -import { useWorkspace } from "@/hooks/store/use-workspace"; -// plane web imports -import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill"; - -export const SettingsSidebarHeader = observer(function SettingsSidebarHeader(props: { - customHeader?: React.ReactNode; -}) { - const { customHeader } = props; - const { currentWorkspace } = useWorkspace(); - return customHeader - ? customHeader - : currentWorkspace && ( -
-
- -
-
- {currentWorkspace.name ?? "Workspace"} -
-
- {getUserRole(currentWorkspace.role)?.toLowerCase() || "guest"} -
-
-
-
- -
-
- ); -}); diff --git a/apps/web/core/components/settings/sidebar/item.tsx b/apps/web/core/components/settings/sidebar/item.tsx new file mode 100644 index 0000000000..2fdd804454 --- /dev/null +++ b/apps/web/core/components/settings/sidebar/item.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import Link from "next/link"; +// plane imports +import { cn } from "@plane/utils"; +import type { LucideIcon } from "lucide-react"; +import type { ISvgIcons } from "@plane/propel/icons"; + +type Props = { + isActive: boolean; + label: string; +} & ({ as: "button"; onClick: () => void } | { as: "link"; href: string }) & + ( + | { + icon: LucideIcon | React.FC; + } + | { iconNode: React.ReactElement } + ); + +export function SettingsSidebarItem(props: Props) { + const { as, isActive, label } = props; + // common class + const className = cn( + "flex items-center gap-2 py-1.5 px-2 rounded-lg text-body-sm-medium text-secondary text-left transition-colors", + { + "bg-layer-transparent-selected text-primary": isActive, + "hover:bg-layer-transparent-hover": !isActive, + } + ); + // common content + const content = ( + <> + {"icon" in props ? ( + {} + ) : ( + props.iconNode + )} + {label} + + ); + + if (as === "button") { + return ( + + ); + } + + return ( + + {content} + + ); +} diff --git a/apps/web/core/components/settings/sidebar/nav-item.tsx b/apps/web/core/components/settings/sidebar/nav-item.tsx deleted file mode 100644 index 0bdf797dcb..0000000000 --- a/apps/web/core/components/settings/sidebar/nav-item.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useState } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { Disclosure } from "@headlessui/react"; -// plane imports -import { useTranslation } from "@plane/i18n"; -import type { EUserWorkspaceRoles } from "@plane/types"; -import { cn, joinUrlPath } from "@plane/utils"; -// hooks -import { useUserSettings } from "@/hooks/store/user"; - -export type TSettingItem = { - key: string; - i18n_label: string; - href: string; - access?: EUserWorkspaceRoles[]; - icon?: React.ReactNode; -}; -export type TSettingsSidebarNavItemProps = { - workspaceSlug: string; - setting: TSettingItem; - isActive: boolean | ((data: { href: string }) => boolean); - actionIcons?: (props: { type: string; size?: number; className?: string }) => React.ReactNode; - appendItemsToTitle?: (key: string) => React.ReactNode; - renderChildren?: (key: string) => React.ReactNode; -}; - -const SettingsSidebarNavItem = observer(function SettingsSidebarNavItem(props: TSettingsSidebarNavItemProps) { - const { workspaceSlug, setting, isActive, actionIcons, appendItemsToTitle, renderChildren } = props; - // router - const { projectId } = useParams(); - // i18n - const { t } = useTranslation(); - // state - const [isExpanded, setIsExpanded] = useState(projectId === setting.key); - // hooks - const { toggleSidebar } = useUserSettings(); - // derived - const isItemActive = typeof isActive === "function" ? isActive(setting) : isActive; - const buttonClass = cn("flex w-full items-center px-2 py-1.5 rounded-sm text-secondary justify-between", { - "bg-layer-transparent-active hover:bg-layer-transparent-active": isItemActive, - "hover:bg-layer-transparent-hover": !isItemActive, - }); - - const titleElement = ( - <> -
- {setting.icon - ? setting.icon - : actionIcons && actionIcons({ type: setting.key, size: 16, className: "w-4 h-4" })} -
{t(setting.i18n_label)}
-
- {appendItemsToTitle?.(setting.key)} - - ); - - return ( - - setIsExpanded(!isExpanded)} - > - {renderChildren ? ( -
{titleElement}
- ) : ( - toggleSidebar(true)} - > - {titleElement} - - )} -
- {/* Nested Navigation */} - {isExpanded && ( - -
- {renderChildren?.(setting.key)} - - )} - - ); -}); - -export default SettingsSidebarNavItem; diff --git a/apps/web/core/components/settings/sidebar/root.tsx b/apps/web/core/components/settings/sidebar/root.tsx deleted file mode 100644 index 74433a69cb..0000000000 --- a/apps/web/core/components/settings/sidebar/root.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { observer } from "mobx-react"; -import { useTranslation } from "@plane/i18n"; -import { cn } from "@plane/utils"; -import { SettingsSidebarHeader } from "./header"; -import type { TSettingItem } from "./nav-item"; -import SettingsSidebarNavItem from "./nav-item"; - -interface SettingsSidebarProps { - isMobile?: boolean; - customHeader?: React.ReactNode; - categories: string[]; - groupedSettings: { - [key: string]: TSettingItem[]; - }; - workspaceSlug: string; - isActive: boolean | ((data: { href: string }) => boolean); - shouldRender: boolean | ((setting: TSettingItem) => boolean); - actionIcons?: (props: { type: string; size?: number; className?: string }) => React.ReactNode; - appendItemsToTitle?: (key: string) => React.ReactNode; - renderChildren?: (key: string) => React.ReactNode; -} - -export const SettingsSidebar = observer(function SettingsSidebar(props: SettingsSidebarProps) { - const { - isMobile = false, - customHeader, - categories, - groupedSettings, - workspaceSlug, - isActive, - shouldRender, - actionIcons, - appendItemsToTitle, - renderChildren, - } = props; - // hooks - const { t } = useTranslation(); - - return ( -
- {/* Header */} - - {/* Navigation */} -
- {categories.map((category) => { - if (groupedSettings[category].length === 0) return null; - return ( -
- {t(category)} -
- {groupedSettings[category].map( - (setting) => - (typeof shouldRender === "function" ? shouldRender(setting) : shouldRender) && ( - - ) - )} -
-
- ); - })} -
-
- ); -}); diff --git a/apps/web/core/components/settings/tabs.tsx b/apps/web/core/components/settings/tabs.tsx deleted file mode 100644 index eac70d34af..0000000000 --- a/apps/web/core/components/settings/tabs.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams, usePathname } from "next/navigation"; -import { cn } from "@plane/utils"; -import { useProject } from "@/hooks/store/use-project"; - -const TABS = { - account: { - key: "account", - label: "Account", - href: `/settings/account/`, - }, - workspace: { - key: "workspace", - label: "Workspace", - href: `/settings/`, - }, - projects: { - key: "projects", - label: "Projects", - href: `/settings/projects/`, - }, -}; - -const SettingsTabs = observer(function SettingsTabs() { - // router - const pathname = usePathname(); - const { workspaceSlug } = useParams(); - // store hooks - const { joinedProjectIds } = useProject(); - - const currentTab = pathname.includes(TABS.projects.href) - ? TABS.projects - : pathname.includes(TABS.account.href) - ? TABS.account - : TABS.workspace; - - return ( -
- {Object.values(TABS).map((tab) => { - const isActive = currentTab?.key === tab.key; - const href = tab.key === TABS.projects.key ? `${tab.href}${joinedProjectIds[0] || ""}` : tab.href; - return ( - -
{tab.label}
- - ); - })} -
- ); -}); - -export default SettingsTabs; diff --git a/apps/web/core/components/settings/workspace/sidebar/header.tsx b/apps/web/core/components/settings/workspace/sidebar/header.tsx new file mode 100644 index 0000000000..534e50271f --- /dev/null +++ b/apps/web/core/components/settings/workspace/sidebar/header.tsx @@ -0,0 +1,60 @@ +import { ArrowLeft } from "lucide-react"; +import { observer } from "mobx-react"; +// plane imports +import { ROLE_DETAILS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { IconButton } from "@plane/propel/icon-button"; +// components +import { WorkspaceLogo } from "@/components/workspace/logo"; +// hooks +import { useUserPermissions } from "@/hooks/store/user"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +// plane web imports +import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill"; + +export const WorkspaceSettingsSidebarHeader = observer(function WorkspaceSettingsSidebarHeader() { + // router + const router = useAppRouter(); + // store hooks + const { getWorkspaceRoleByWorkspaceSlug } = useUserPermissions(); + const { currentWorkspace } = useWorkspace(); + // derived values + const currentWorkspaceRole = currentWorkspace?.slug + ? getWorkspaceRoleByWorkspaceSlug(currentWorkspace.slug) + : undefined; + // translation + const { t } = useTranslation(); + + if (!currentWorkspaceRole) return null; + + return ( +
+
+ router.push(`/${currentWorkspace?.slug}/`)} + /> +

Workspace settings

+
+
+
+ +
+

{currentWorkspace?.name}

+

{t(ROLE_DETAILS[currentWorkspaceRole].i18n_title)}

+
+
+
+ +
+
+
+ ); +}); diff --git a/apps/web/core/components/settings/workspace/sidebar/index.ts b/apps/web/core/components/settings/workspace/sidebar/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/apps/web/core/components/settings/workspace/sidebar/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/core/components/settings/workspace/sidebar/item-categories.tsx b/apps/web/core/components/settings/workspace/sidebar/item-categories.tsx new file mode 100644 index 0000000000..9223ba8c62 --- /dev/null +++ b/apps/web/core/components/settings/workspace/sidebar/item-categories.tsx @@ -0,0 +1,61 @@ +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +import { useParams } from "react-router"; +// plane imports +import { EUserPermissionsLevel, GROUPED_WORKSPACE_SETTINGS, WORKSPACE_SETTINGS_CATEGORIES } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { joinUrlPath } from "@plane/utils"; +// components +import { SettingsSidebarItem } from "@/components/settings/sidebar/item"; +// hooks +import { useUserPermissions } from "@/hooks/store/user"; +// local imports +import { WORKSPACE_SETTINGS_ICONS } from "./item-icon"; + +export const WorkspaceSettingsSidebarItemCategories = observer(function WorkspaceSettingsSidebarItemCategories() { + // params + const { workspaceSlug } = useParams(); + const pathname = usePathname(); + // store hooks + const { allowPermissions } = useUserPermissions(); + // translation + const { t } = useTranslation(); + + return ( +
+ {WORKSPACE_SETTINGS_CATEGORIES.map((category) => { + const categoryItems = GROUPED_WORKSPACE_SETTINGS[category]; + const accessibleItems = categoryItems.filter((item) => + allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug) + ); + + if (accessibleItems.length === 0) return null; + + return ( +
+
{t(category)}
+
+ {accessibleItems.map((item) => { + const isItemActive = + item.href === "/settings" + ? pathname === `/${workspaceSlug}${item.href}/` + : new RegExp(`^/${workspaceSlug}${item.href}/`).test(pathname); + + return ( + + ); + })} +
+
+ ); + })} +
+ ); +}); diff --git a/apps/web/core/components/settings/workspace/sidebar/item-icon.tsx b/apps/web/core/components/settings/workspace/sidebar/item-icon.tsx new file mode 100644 index 0000000000..3a34f969ba --- /dev/null +++ b/apps/web/core/components/settings/workspace/sidebar/item-icon.tsx @@ -0,0 +1,13 @@ +import type { LucideIcon } from "lucide-react"; +import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react"; +// plane imports +import type { ISvgIcons } from "@plane/propel/icons"; +import type { TWorkspaceSettingsTabs } from "@plane/types"; + +export const WORKSPACE_SETTINGS_ICONS: Record> = { + general: Building, + members: Users, + export: ArrowUpToLine, + "billing-and-plans": CreditCard, + webhooks: Webhook, +}; diff --git a/apps/web/core/components/settings/workspace/sidebar/root.tsx b/apps/web/core/components/settings/workspace/sidebar/root.tsx new file mode 100644 index 0000000000..f3f1e10e00 --- /dev/null +++ b/apps/web/core/components/settings/workspace/sidebar/root.tsx @@ -0,0 +1,29 @@ +// plane imports +import { ScrollArea } from "@plane/propel/scrollarea"; +import { cn } from "@plane/utils"; +// local imports +import { WorkspaceSettingsSidebarHeader } from "./header"; +import { WorkspaceSettingsSidebarItemCategories } from "./item-categories"; + +type Props = { + className?: string; +}; + +export function WorkspaceSettingsSidebarRoot(props: Props) { + const { className } = props; + + return ( + + + + + ); +} diff --git a/apps/web/core/components/sidebar/sidebar-wrapper.tsx b/apps/web/core/components/sidebar/sidebar-wrapper.tsx index c6931444a1..28bc3d2845 100644 --- a/apps/web/core/components/sidebar/sidebar-wrapper.tsx +++ b/apps/web/core/components/sidebar/sidebar-wrapper.tsx @@ -44,7 +44,7 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr return ( <> setIsCustomizeNavDialogOpen(false)} /> -
+
{/* Workspace switcher and settings */} diff --git a/apps/web/core/components/web-hooks/webhooks-list-item.tsx b/apps/web/core/components/web-hooks/webhooks-list-item.tsx index 1355406f7a..3795d40e1d 100644 --- a/apps/web/core/components/web-hooks/webhooks-list-item.tsx +++ b/apps/web/core/components/web-hooks/webhooks-list-item.tsx @@ -23,12 +23,15 @@ export function WebhooksListItem(props: IWebhookListItem) { }; return ( -
- - -
{webhook.url}
+
+ +
{webhook.url}
+
- +
); diff --git a/apps/web/core/components/web-hooks/webhooks-list.tsx b/apps/web/core/components/web-hooks/webhooks-list.tsx index fe21e209da..62043596c5 100644 --- a/apps/web/core/components/web-hooks/webhooks-list.tsx +++ b/apps/web/core/components/web-hooks/webhooks-list.tsx @@ -9,7 +9,7 @@ export const WebhooksList = observer(function WebhooksList() { const { webhooks } = useWebhook(); return ( -
+
{Object.values(webhooks ?? {}).map((webhook) => ( ))} diff --git a/apps/web/core/components/workspace/billing/comparison/base.tsx b/apps/web/core/components/workspace/billing/comparison/base.tsx index 4ab5576a56..f198907f00 100644 --- a/apps/web/core/components/workspace/billing/comparison/base.tsx +++ b/apps/web/core/components/workspace/billing/comparison/base.tsx @@ -35,7 +35,7 @@ export const PlansComparisonBase = observer(function PlansComparisonBase(props: const getSubscriptionType = (planKey: TPlanePlans) => planDetails[planKey].id; return ( -
+
diff --git a/apps/web/core/components/workspace/settings/members-list.tsx b/apps/web/core/components/workspace/settings/members-list.tsx index f1560c143f..1dae187643 100644 --- a/apps/web/core/components/workspace/settings/members-list.tsx +++ b/apps/web/core/components/workspace/settings/members-list.tsx @@ -66,7 +66,7 @@ export const WorkspaceMembersList = observer(function WorkspaceMembersList(props return ( <> -
+
{searchedMemberIds?.length !== 0 && } {searchedInvitationsIds?.length === 0 && searchedMemberIds?.length === 0 && (

{t("no_matching_members")}

diff --git a/apps/web/core/components/workspace/settings/workspace-details.tsx b/apps/web/core/components/workspace/settings/workspace-details.tsx index 5bcc832d2d..66cb59c720 100644 --- a/apps/web/core/components/workspace/settings/workspace-details.tsx +++ b/apps/web/core/components/workspace/settings/workspace-details.tsx @@ -9,7 +9,7 @@ import { EditIcon } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IWorkspace } from "@plane/types"; import { CustomSelect, Input } from "@plane/ui"; -import { copyUrlToClipboard, getFileURL } from "@plane/utils"; +import { cn, copyUrlToClipboard, getFileURL } from "@plane/utils"; // components import { WorkspaceImageUploadModal } from "@/components/core/modals/workspace-image-upload-modal"; import { TimezoneSelect } from "@/components/global/timezone-select"; @@ -119,7 +119,8 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() { const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); - if (!currentWorkspace) return <>; + if (!currentWorkspace) return null; + return ( <> )} /> -
-
-
+
+
+
{isAdmin && (
- -
+
-
-

{t("workspace_settings.settings.general.name")}

+
+

{t("workspace_settings.settings.general.name")}

- -
-

+
+

{t("workspace_settings.settings.general.company_size")}

- -
-

{t("workspace_settings.settings.general.url")}

+
+

{t("workspace_settings.settings.general.url")}

- -
-

+
+

{t("workspace_settings.settings.general.workspace_timezone")}

( <> - + )} />

- - {isAdmin && ( -
- -
- )}
- {isAdmin && } + {isAdmin && ( +
+ +
+ )}

+ {isAdmin && ( +
+ +
+ )} ); }); diff --git a/apps/web/core/components/workspace/sidebar/user-menu-root.tsx b/apps/web/core/components/workspace/sidebar/user-menu-root.tsx index 2183492c53..8fbd13a2d5 100644 --- a/apps/web/core/components/workspace/sidebar/user-menu-root.tsx +++ b/apps/web/core/components/workspace/sidebar/user-menu-root.tsx @@ -1,7 +1,6 @@ import { useState, useEffect } from "react"; import { observer } from "mobx-react"; -import { useParams, useRouter } from "next/navigation"; -// icons +import { useRouter } from "next/navigation"; import { LogOut, Settings, Settings2 } from "lucide-react"; // plane imports import { GOD_MODE_URL } from "@plane/constants"; @@ -9,33 +8,31 @@ import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Avatar, CustomMenu } from "@plane/ui"; import { getFileURL } from "@plane/utils"; -// hooks +// components +import { CoverImage } from "@/components/common/cover-image"; import { AppSidebarItem } from "@/components/sidebar/sidebar-item"; +// hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useUser } from "@/hooks/store/user"; -type Props = { - size?: "xs" | "sm" | "md"; -}; - -export const UserMenuRoot = observer(function UserMenuRoot(props: Props) { - const { size = "sm" } = props; - const { workspaceSlug } = useParams(); +export const UserMenuRoot = observer(function UserMenuRoot() { + // states + const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); // router const router = useRouter(); // store hooks const { toggleAnySidebarDropdown } = useAppTheme(); const { data: currentUser } = useUser(); const { signOut } = useUser(); + const { toggleProfileSettingsModal } = useCommandPalette(); // derived values const isUserInstanceAdmin = false; // translation const { t } = useTranslation(); - // local state - const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); - const handleSignOut = async () => { - await signOut().catch(() => + const handleSignOut = () => { + signOut().catch(() => setToast({ type: TOAST_TYPE.ERROR, title: t("sign_out.toast.error.title"), @@ -48,7 +45,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: Props) { useEffect(() => { if (isUserMenuOpen) toggleAnySidebarDropdown(true); else toggleAnySidebarDropdown(false); - }, [isUserMenuOpen]); + }, [isUserMenuOpen, toggleAnySidebarDropdown]); return ( ), @@ -72,48 +69,75 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: Props) { menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)} onMenuClose={() => setIsUserMenuOpen(false)} placement="bottom-end" - maxHeight="lg" + maxHeight="2xl" + optionsClassName="w-72 p-3 flex flex-col gap-y-3" closeOnSelect > -
- {currentUser?.email} - router.push(`/${workspaceSlug}/settings/account`)}> -
- - {t("settings")} +
+ +
+
+
+
+ +
+
+

+ {currentUser?.first_name} {currentUser?.last_name} +

+

{currentUser?.email}

+
+
+
+
+ + toggleProfileSettingsModal({ + activeTab: "general", + isOpen: true, + }) + } + className="flex items-center gap-2" + > + + {t("settings")} - router.push(`/${workspaceSlug}/settings/account/preferences`)}> -
- - Preferences -
-
-
-
-
- - + + toggleProfileSettingsModal({ + activeTab: "preferences", + isOpen: true, + }) + } + className="flex items-center gap-2" + > + + {t("preferences")}
+ + + {t("sign_out")} + {isUserInstanceAdmin && ( - <> -
-
- router.push(GOD_MODE_URL)}> -
- {t("enter_god_mode")} -
-
-
- + router.push(GOD_MODE_URL)} + className="bg-accent-primary/20 text-accent-primary hover:bg-accent-primary/30 hover:text-accent-secondary" + > + {t("enter_god_mode")} + )} ); diff --git a/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx b/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx index 256afec7df..394fdabf38 100644 --- a/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx +++ b/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx @@ -178,15 +178,12 @@ export const WorkspaceAuthWrapper = observer(function WorkspaceAuthWrapper(props )} {allWorkspaces?.length > 0 && ( - + Visit Profile )} {allWorkspaces && allWorkspaces.length === 0 && ( - + Create new workspace )} diff --git a/apps/web/core/store/base-command-palette.store.ts b/apps/web/core/store/base-command-palette.store.ts index d48769f7e2..e459295303 100644 --- a/apps/web/core/store/base-command-palette.store.ts +++ b/apps/web/core/store/base-command-palette.store.ts @@ -1,8 +1,11 @@ -import { observable, action, makeObservable } from "mobx"; +import { observable, action, makeObservable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; +// plane imports import type { TCreateModalStoreTypes, TCreatePageModal } from "@plane/constants"; import { DEFAULT_CREATE_PAGE_MODAL_DATA, EPageAccess } from "@plane/constants"; +import type { TProfileSettingsTabs } from "@plane/types"; import { EIssuesStoreType } from "@plane/types"; +// lib import { store } from "@/lib/store-context"; export interface ModalData { @@ -22,6 +25,10 @@ export interface IBaseCommandPaletteStore { isBulkDeleteIssueModalOpen: boolean; createIssueStoreType: TCreateModalStoreTypes; createWorkItemAllowedProjectIds: string[] | undefined; + profileSettingsModal: { + activeTab: TProfileSettingsTabs | null; + isOpen: boolean; + }; allStickiesModal: boolean; projectListOpenMap: Record; getIsProjectListOpen: (projectId: string) => boolean; @@ -36,6 +43,7 @@ export interface IBaseCommandPaletteStore { toggleBulkDeleteIssueModal: (value?: boolean) => void; toggleAllStickiesModal: (value?: boolean) => void; toggleProjectListOpen: (projectId: string, value?: boolean) => void; + toggleProfileSettingsModal: (value: { activeTab?: TProfileSettingsTabs | null; isOpen?: boolean }) => void; } export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStore { @@ -50,6 +58,10 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor createPageModal: TCreatePageModal = DEFAULT_CREATE_PAGE_MODAL_DATA; createIssueStoreType: TCreateModalStoreTypes = EIssuesStoreType.PROJECT; createWorkItemAllowedProjectIds: IBaseCommandPaletteStore["createWorkItemAllowedProjectIds"] = undefined; + profileSettingsModal: IBaseCommandPaletteStore["profileSettingsModal"] = { + activeTab: "general", + isOpen: false, + }; allStickiesModal: boolean = false; projectListOpenMap: Record = {}; @@ -66,6 +78,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor createPageModal: observable, createIssueStoreType: observable, createWorkItemAllowedProjectIds: observable, + profileSettingsModal: observable, allStickiesModal: observable, projectListOpenMap: observable, // toggle actions @@ -79,6 +92,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor toggleBulkDeleteIssueModal: action, toggleAllStickiesModal: action, toggleProjectListOpen: action, + toggleProfileSettingsModal: action, }); } @@ -240,4 +254,20 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor this.allStickiesModal = !this.allStickiesModal; } }; + + /** + * Toggles the profile settings modal + * @param value + * @returns + */ + toggleProfileSettingsModal: IBaseCommandPaletteStore["toggleProfileSettingsModal"] = (payload) => { + const updatedSettings: IBaseCommandPaletteStore["profileSettingsModal"] = { + ...this.profileSettingsModal, + ...payload, + }; + + runInAction(() => { + this.profileSettingsModal = updatedSettings; + }); + }; } diff --git a/apps/web/ee/constants/project/settings/tabs.ts b/apps/web/ee/constants/project/settings/tabs.ts deleted file mode 100644 index 0f004a8325..0000000000 --- a/apps/web/ee/constants/project/settings/tabs.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "ce/constants/project/settings/tabs"; diff --git a/packages/constants/src/profile.ts b/packages/constants/src/profile.ts index cb90d62d3b..1dfa3c41a0 100644 --- a/packages/constants/src/profile.ts +++ b/packages/constants/src/profile.ts @@ -1,57 +1,6 @@ +// plane imports import { EStartOfTheWeek } from "@plane/types"; -export const PROFILE_SETTINGS = { - profile: { - key: "profile", - i18n_label: "profile.actions.profile", - href: `/settings/account`, - highlight: (pathname: string) => pathname === "/settings/account/", - }, - security: { - key: "security", - i18n_label: "profile.actions.security", - href: `/settings/account/security`, - highlight: (pathname: string) => pathname === "/settings/account/security/", - }, - activity: { - key: "activity", - i18n_label: "profile.actions.activity", - href: `/settings/account/activity`, - highlight: (pathname: string) => pathname === "/settings/account/activity/", - }, - preferences: { - key: "preferences", - i18n_label: "profile.actions.preferences", - href: `/settings/account/preferences`, - highlight: (pathname: string) => pathname === "/settings/account/preferences", - }, - notifications: { - key: "notifications", - i18n_label: "profile.actions.notifications", - href: `/settings/account/notifications`, - highlight: (pathname: string) => pathname === "/settings/account/notifications/", - }, - "api-tokens": { - key: "api-tokens", - i18n_label: "profile.actions.api-tokens", - href: `/settings/account/api-tokens`, - highlight: (pathname: string) => pathname === "/settings/account/api-tokens/", - }, -}; -export const PROFILE_ACTION_LINKS: { - key: string; - i18n_label: string; - href: string; - highlight: (pathname: string) => boolean; -}[] = [ - PROFILE_SETTINGS["profile"], - PROFILE_SETTINGS["security"], - PROFILE_SETTINGS["activity"], - PROFILE_SETTINGS["preferences"], - PROFILE_SETTINGS["notifications"], - PROFILE_SETTINGS["api-tokens"], -]; - export const PROFILE_VIEWER_TAB = [ { key: "summary", @@ -98,11 +47,6 @@ export const PREFERENCE_OPTIONS: { title: "theme", description: "select_or_customize_your_interface_color_scheme", }, - { - id: "start_of_week", - title: "First day of the week", - description: "This will change how all calendars in your app look.", - }, ]; /** diff --git a/packages/constants/src/settings.ts b/packages/constants/src/settings.ts deleted file mode 100644 index 2c55a6a2dd..0000000000 --- a/packages/constants/src/settings.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { PROFILE_SETTINGS } from "./profile"; -import { WORKSPACE_SETTINGS } from "./workspace"; - -export enum WORKSPACE_SETTINGS_CATEGORY { - ADMINISTRATION = "administration", - FEATURES = "features", - DEVELOPER = "developer", -} - -export enum PROFILE_SETTINGS_CATEGORY { - YOUR_PROFILE = "your profile", - DEVELOPER = "developer", -} - -export enum PROJECT_SETTINGS_CATEGORY { - PROJECTS = "projects", -} - -export const WORKSPACE_SETTINGS_CATEGORIES = [ - WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION, - WORKSPACE_SETTINGS_CATEGORY.FEATURES, - WORKSPACE_SETTINGS_CATEGORY.DEVELOPER, -]; - -export const PROFILE_SETTINGS_CATEGORIES = [ - PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE, - PROFILE_SETTINGS_CATEGORY.DEVELOPER, -]; - -export const PROJECT_SETTINGS_CATEGORIES = [PROJECT_SETTINGS_CATEGORY.PROJECTS]; - -export const GROUPED_WORKSPACE_SETTINGS = { - [WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION]: [ - WORKSPACE_SETTINGS["general"], - WORKSPACE_SETTINGS["members"], - WORKSPACE_SETTINGS["billing-and-plans"], - WORKSPACE_SETTINGS["export"], - ], - [WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [], - [WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]], -}; - -export const GROUPED_PROFILE_SETTINGS = { - [PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [ - PROFILE_SETTINGS["profile"], - PROFILE_SETTINGS["preferences"], - PROFILE_SETTINGS["notifications"], - PROFILE_SETTINGS["security"], - PROFILE_SETTINGS["activity"], - ], - [PROFILE_SETTINGS_CATEGORY.DEVELOPER]: [PROFILE_SETTINGS["api-tokens"]], -}; diff --git a/packages/constants/src/settings/index.ts b/packages/constants/src/settings/index.ts new file mode 100644 index 0000000000..e24ff3c651 --- /dev/null +++ b/packages/constants/src/settings/index.ts @@ -0,0 +1,3 @@ +export * from "./profile"; +export * from "./project"; +export * from "./workspace"; diff --git a/packages/constants/src/settings/profile.ts b/packages/constants/src/settings/profile.ts new file mode 100644 index 0000000000..ea5ed5a6fe --- /dev/null +++ b/packages/constants/src/settings/profile.ts @@ -0,0 +1,61 @@ +// plane imports +import type { TProfileSettingsTabs } from "@plane/types"; + +export enum PROFILE_SETTINGS_CATEGORY { + YOUR_PROFILE = "your profile", + DEVELOPER = "developer", +} + +export const PROFILE_SETTINGS_CATEGORIES: PROFILE_SETTINGS_CATEGORY[] = [ + PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE, + PROFILE_SETTINGS_CATEGORY.DEVELOPER, +]; + +export const PROFILE_SETTINGS: Record< + TProfileSettingsTabs, + { + key: TProfileSettingsTabs; + i18n_label: string; + } +> = { + general: { + key: "general", + i18n_label: "profile.actions.profile", + }, + security: { + key: "security", + i18n_label: "profile.actions.security", + }, + activity: { + key: "activity", + i18n_label: "profile.actions.activity", + }, + preferences: { + key: "preferences", + i18n_label: "profile.actions.preferences", + }, + notifications: { + key: "notifications", + i18n_label: "profile.actions.notifications", + }, + "api-tokens": { + key: "api-tokens", + i18n_label: "profile.actions.api-tokens", + }, +}; + +export const PROFILE_SETTINGS_TABS: TProfileSettingsTabs[] = Object.keys(PROFILE_SETTINGS) as TProfileSettingsTabs[]; + +export const GROUPED_PROFILE_SETTINGS: Record< + PROFILE_SETTINGS_CATEGORY, + { key: TProfileSettingsTabs; i18n_label: string }[] +> = { + [PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [ + PROFILE_SETTINGS["general"], + PROFILE_SETTINGS["preferences"], + PROFILE_SETTINGS["notifications"], + PROFILE_SETTINGS["security"], + PROFILE_SETTINGS["activity"], + ], + [PROFILE_SETTINGS_CATEGORY.DEVELOPER]: [PROFILE_SETTINGS["api-tokens"]], +}; diff --git a/packages/constants/src/settings/project.ts b/packages/constants/src/settings/project.ts new file mode 100644 index 0000000000..7704c22fd2 --- /dev/null +++ b/packages/constants/src/settings/project.ts @@ -0,0 +1,116 @@ +// plane imports +import { EUserProjectRoles } from "@plane/types"; +import type { TProjectSettingsItem, TProjectSettingsTabs } from "@plane/types"; + +export enum PROJECT_SETTINGS_CATEGORY { + GENERAL = "general", + FEATURES = "features", + WORK_STRUCTURE = "work-structure", + EXECUTION = "execution", +} + +export const PROJECT_SETTINGS_CATEGORIES: PROJECT_SETTINGS_CATEGORY[] = [ + PROJECT_SETTINGS_CATEGORY.GENERAL, + PROJECT_SETTINGS_CATEGORY.FEATURES, + PROJECT_SETTINGS_CATEGORY.WORK_STRUCTURE, + PROJECT_SETTINGS_CATEGORY.EXECUTION, +]; + +export const PROJECT_SETTINGS: Record = { + general: { + key: "general", + i18n_label: "common.general", + href: ``, + access: [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER, EUserProjectRoles.GUEST], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`, + }, + members: { + key: "members", + i18n_label: "common.members", + href: `/members`, + access: [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER, EUserProjectRoles.GUEST], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/members/`, + }, + features_cycles: { + key: "features_cycles", + i18n_label: "project_settings.features.cycles.short_title", + href: `/features/cycles`, + access: [EUserProjectRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/cycles/`, + }, + features_modules: { + key: "features_modules", + i18n_label: "project_settings.features.modules.short_title", + href: `/features/modules`, + access: [EUserProjectRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/modules/`, + }, + features_views: { + key: "features_views", + i18n_label: "project_settings.features.views.short_title", + href: `/features/views`, + access: [EUserProjectRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/views/`, + }, + features_pages: { + key: "features_pages", + i18n_label: "project_settings.features.pages.short_title", + href: `/features/pages`, + access: [EUserProjectRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/pages/`, + }, + features_intake: { + key: "features_intake", + i18n_label: "project_settings.features.intake.short_title", + href: `/features/intake`, + access: [EUserProjectRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/features/intake/`, + }, + states: { + key: "states", + i18n_label: "common.states", + href: `/states`, + access: [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/states/`, + }, + labels: { + key: "labels", + i18n_label: "common.labels", + href: `/labels`, + access: [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/labels/`, + }, + estimates: { + key: "estimates", + i18n_label: "common.estimates", + href: `/estimates`, + access: [EUserProjectRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/estimates/`, + }, + automations: { + key: "automations", + i18n_label: "project_settings.automations.label", + href: `/automations`, + access: [EUserProjectRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/automations/`, + }, +}; + +export const PROJECT_SETTINGS_FLAT_MAP: TProjectSettingsItem[] = Object.values(PROJECT_SETTINGS); + +export const GROUPED_PROJECT_SETTINGS: Record = { + [PROJECT_SETTINGS_CATEGORY.GENERAL]: [PROJECT_SETTINGS["general"], PROJECT_SETTINGS["members"]], + [PROJECT_SETTINGS_CATEGORY.FEATURES]: [ + PROJECT_SETTINGS["features_cycles"], + PROJECT_SETTINGS["features_modules"], + PROJECT_SETTINGS["features_views"], + PROJECT_SETTINGS["features_pages"], + PROJECT_SETTINGS["features_intake"], + ], + [PROJECT_SETTINGS_CATEGORY.WORK_STRUCTURE]: [ + PROJECT_SETTINGS["states"], + PROJECT_SETTINGS["labels"], + PROJECT_SETTINGS["estimates"], + ], + [PROJECT_SETTINGS_CATEGORY.EXECUTION]: [PROJECT_SETTINGS["automations"]], +}; diff --git a/packages/constants/src/settings/workspace.ts b/packages/constants/src/settings/workspace.ts new file mode 100644 index 0000000000..d56b989aaa --- /dev/null +++ b/packages/constants/src/settings/workspace.ts @@ -0,0 +1,68 @@ +// plane imports +import type { TWorkspaceSettingsItem, TWorkspaceSettingsTabs } from "@plane/types"; +import { EUserWorkspaceRoles } from "@plane/types"; + +export enum WORKSPACE_SETTINGS_CATEGORY { + ADMINISTRATION = "administration", + FEATURES = "features", + DEVELOPER = "developer", +} + +export const WORKSPACE_SETTINGS_CATEGORIES: WORKSPACE_SETTINGS_CATEGORY[] = [ + WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION, + WORKSPACE_SETTINGS_CATEGORY.FEATURES, + WORKSPACE_SETTINGS_CATEGORY.DEVELOPER, +]; + +export const WORKSPACE_SETTINGS: Record = { + general: { + key: "general", + i18n_label: "workspace_settings.settings.general.title", + href: `/settings`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, + }, + members: { + key: "members", + i18n_label: "workspace_settings.settings.members.title", + href: `/settings/members`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, + }, + "billing-and-plans": { + key: "billing-and-plans", + i18n_label: "workspace_settings.settings.billing_and_plans.title", + href: `/settings/billing`, + access: [EUserWorkspaceRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing/`, + }, + export: { + key: "export", + i18n_label: "workspace_settings.settings.exports.title", + href: `/settings/exports`, + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`, + }, + webhooks: { + key: "webhooks", + i18n_label: "workspace_settings.settings.webhooks.title", + href: `/settings/webhooks`, + access: [EUserWorkspaceRoles.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`, + }, +}; + +export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries( + Object.entries(WORKSPACE_SETTINGS).map(([_, { href, access }]) => [href, access]) +); + +export const GROUPED_WORKSPACE_SETTINGS: Record = { + [WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION]: [ + WORKSPACE_SETTINGS["general"], + WORKSPACE_SETTINGS["members"], + WORKSPACE_SETTINGS["billing-and-plans"], + WORKSPACE_SETTINGS["export"], + ], + [WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [], + [WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]], +}; diff --git a/packages/constants/src/workspace.ts b/packages/constants/src/workspace.ts index 9610333c0e..f2cf5be972 100644 --- a/packages/constants/src/workspace.ts +++ b/packages/constants/src/workspace.ts @@ -1,9 +1,9 @@ import type { TStaticViewTypes, IWorkspaceSearchResults } from "@plane/types"; import { EUserWorkspaceRoles } from "@plane/types"; -export const ORGANIZATION_SIZE = ["Just myself", "2-10", "11-50", "51-200", "201-500", "500+"]; +export const ORGANIZATION_SIZE: string[] = ["Just myself", "2-10", "11-50", "51-200", "201-500", "500+"]; -export const RESTRICTED_URLS = [ +export const RESTRICTED_URLS: string[] = [ "404", "accounts", "api", @@ -71,62 +71,6 @@ export const RESTRICTED_URLS = [ "instance", ]; -export const WORKSPACE_SETTINGS = { - general: { - key: "general", - i18n_label: "workspace_settings.settings.general.title", - href: `/settings`, - access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, - }, - members: { - key: "members", - i18n_label: "workspace_settings.settings.members.title", - href: `/settings/members`, - access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, - }, - "billing-and-plans": { - key: "billing-and-plans", - i18n_label: "workspace_settings.settings.billing_and_plans.title", - href: `/settings/billing`, - access: [EUserWorkspaceRoles.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing/`, - }, - export: { - key: "export", - i18n_label: "workspace_settings.settings.exports.title", - href: `/settings/exports`, - access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`, - }, - webhooks: { - key: "webhooks", - i18n_label: "workspace_settings.settings.webhooks.title", - href: `/settings/webhooks`, - access: [EUserWorkspaceRoles.ADMIN], - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`, - }, -}; - -export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries( - Object.entries(WORKSPACE_SETTINGS).map(([_, { href, access }]) => [href, access]) -); - -export const WORKSPACE_SETTINGS_LINKS: { - key: string; - i18n_label: string; - href: string; - access: EUserWorkspaceRoles[]; - highlight: (pathname: string, baseUrl: string) => boolean; -}[] = [ - WORKSPACE_SETTINGS["general"], - WORKSPACE_SETTINGS["members"], - WORKSPACE_SETTINGS["billing-and-plans"], - WORKSPACE_SETTINGS["export"], - WORKSPACE_SETTINGS["webhooks"], -]; - export const ROLE = { [EUserWorkspaceRoles.GUEST]: "Guest", [EUserWorkspaceRoles.MEMBER]: "Member", diff --git a/packages/i18n/src/locales/cs/translations.ts b/packages/i18n/src/locales/cs/translations.ts index 3b98f60c20..87fe4f5d99 100644 --- a/packages/i18n/src/locales/cs/translations.ts +++ b/packages/i18n/src/locales/cs/translations.ts @@ -1966,6 +1966,44 @@ export default { primary_button: "Přidat systém odhadů", }, }, + features: { + cycles: { + title: "Cykly", + short_title: "Cykly", + description: + "Naplánujte práci v flexibilních obdobích, která se přizpůsobí jedinečnému rytmu a tempu tohoto projektu.", + toggle_title: "Povolit cykly", + toggle_description: "Naplánujte práci v soustředěných časových rámcích.", + }, + modules: { + title: "Moduly", + short_title: "Moduly", + description: "Organizujte práci do dílčích projektů s vyhrazenými vedoucími a přiřazenými osobami.", + toggle_title: "Povolit moduly", + toggle_description: "Členové projektu budou moci vytvářet a upravovat moduly.", + }, + views: { + title: "Zobrazení", + short_title: "Zobrazení", + description: "Uložte vlastní řazení, filtry a možnosti zobrazení nebo je sdílejte se svým týmem.", + toggle_title: "Povolit zobrazení", + toggle_description: "Členové projektu budou moci vytvářet a upravovat zobrazení.", + }, + pages: { + title: "Stránky", + short_title: "Stránky", + description: "Vytvářejte a upravujte volný obsah: poznámky, dokumenty, cokoliv.", + toggle_title: "Povolit stránky", + toggle_description: "Členové projektu budou moci vytvářet a upravovat stránky.", + }, + intake: { + title: "Příjem", + short_title: "Příjem", + description: "Umožněte nečlenům sdílet chyby, zpětnou vazbu a návrhy; bez narušení vašeho pracovního postupu.", + toggle_title: "Povolit příjem", + toggle_description: "Povolit členům projektu vytvářet žádosti o příjem v aplikaci.", + }, + }, }, project_cycles: { add_cycle: "Přidat cyklus", diff --git a/packages/i18n/src/locales/de/translations.ts b/packages/i18n/src/locales/de/translations.ts index d6b5a7d417..a9e676ec1f 100644 --- a/packages/i18n/src/locales/de/translations.ts +++ b/packages/i18n/src/locales/de/translations.ts @@ -1990,6 +1990,46 @@ export default { primary_button: "Schätzungssystem hinzufügen", }, }, + features: { + cycles: { + title: "Zyklen", + short_title: "Zyklen", + description: + "Planen Sie die Arbeit in flexiblen Zeiträumen, die sich dem einzigartigen Rhythmus und Tempo dieses Projekts anpassen.", + toggle_title: "Zyklen aktivieren", + toggle_description: "Planen Sie die Arbeit in fokussierten Zeiträumen.", + }, + modules: { + title: "Module", + short_title: "Module", + description: "Organisieren Sie die Arbeit in Teilprojekte mit engagierten Leitern und Verantwortlichen.", + toggle_title: "Module aktivieren", + toggle_description: "Projektmitglieder können Module erstellen und bearbeiten.", + }, + views: { + title: "Ansichten", + short_title: "Ansichten", + description: + "Speichern Sie benutzerdefinierte Sortierungen, Filter und Anzeigeoptionen oder teilen Sie sie mit Ihrem Team.", + toggle_title: "Ansichten aktivieren", + toggle_description: "Projektmitglieder können Ansichten erstellen und bearbeiten.", + }, + pages: { + title: "Seiten", + short_title: "Seiten", + description: "Erstellen und bearbeiten Sie freie Inhalte: Notizen, Dokumente, alles.", + toggle_title: "Seiten aktivieren", + toggle_description: "Projektmitglieder können Seiten erstellen und bearbeiten.", + }, + intake: { + title: "Aufnahme", + short_title: "Aufnahme", + description: + "Ermöglichen Sie Nicht-Mitgliedern, Fehler, Feedback und Vorschläge zu teilen, ohne Ihren Workflow zu unterbrechen.", + toggle_title: "Aufnahme aktivieren", + toggle_description: "Projektmitgliedern erlauben, In-App-Aufnahmeanfragen zu erstellen.", + }, + }, }, project_cycles: { add_cycle: "Zyklus hinzufügen", diff --git a/packages/i18n/src/locales/en/translations.ts b/packages/i18n/src/locales/en/translations.ts index d41e7ecb06..c45990182f 100644 --- a/packages/i18n/src/locales/en/translations.ts +++ b/packages/i18n/src/locales/en/translations.ts @@ -1843,6 +1843,43 @@ export default { primary_button: "Add estimate system", }, }, + features: { + cycles: { + title: "Cycles", + short_title: "Cycles", + description: "Schedule work in flexible periods that adapt to this project's unique rhythm and pace.", + toggle_title: "Enable cycles", + toggle_description: "Plan work in focused timeframes.", + }, + modules: { + title: "Modules", + short_title: "Modules", + description: "Organize work into sub-projects with dedicated leads and assignees.", + toggle_title: "Enable modules", + toggle_description: "Project members will be able to create and edit modules.", + }, + views: { + title: "Views", + short_title: "Views", + description: "Save custom sorts, filters, and display options or share them with your team.", + toggle_title: "Enable views", + toggle_description: "Project members will be able to create and edit views.", + }, + pages: { + title: "Pages", + short_title: "Pages", + description: "Create and edit free-form content; notes, docs, anything.", + toggle_title: "Enable pages", + toggle_description: "Project members will be able to create and edit pages.", + }, + intake: { + title: "Intake", + short_title: "Intake", + description: "Let non-members share bugs, feedback, and suggestions; without disrupting your workflow.", + toggle_title: "Enable intake", + toggle_description: "Let project members create in app intake requests.", + }, + }, }, project_cycles: { add_cycle: "Add cycle", diff --git a/packages/i18n/src/locales/es/translations.ts b/packages/i18n/src/locales/es/translations.ts index 85807f743f..302e4b1eb0 100644 --- a/packages/i18n/src/locales/es/translations.ts +++ b/packages/i18n/src/locales/es/translations.ts @@ -1999,6 +1999,46 @@ export default { primary_button: "Agregar sistema de estimación", }, }, + features: { + cycles: { + title: "Ciclos", + short_title: "Ciclos", + description: + "Programa el trabajo en períodos flexibles que se adaptan al ritmo y al tempo únicos de este proyecto.", + toggle_title: "Habilitar ciclos", + toggle_description: "Planifica el trabajo en períodos de tiempo enfocados.", + }, + modules: { + title: "Módulos", + short_title: "Módulos", + description: "Organiza el trabajo en subproyectos con líderes y responsables dedicados.", + toggle_title: "Habilitar módulos", + toggle_description: "Los miembros del proyecto podrán crear y editar módulos.", + }, + views: { + title: "Vistas", + short_title: "Vistas", + description: + "Guarda ordenaciones, filtros y opciones de visualización personalizadas o compártelos con tu equipo.", + toggle_title: "Habilitar vistas", + toggle_description: "Los miembros del proyecto podrán crear y editar vistas.", + }, + pages: { + title: "Páginas", + short_title: "Páginas", + description: "Crea y edita contenido libre: notas, documentos, cualquier cosa.", + toggle_title: "Habilitar páginas", + toggle_description: "Los miembros del proyecto podrán crear y editar páginas.", + }, + intake: { + title: "Recepción", + short_title: "Recepción", + description: + "Permite que los no miembros compartan errores, comentarios y sugerencias; sin interrumpir tu flujo de trabajo.", + toggle_title: "Habilitar recepción", + toggle_description: "Permitir a los miembros del proyecto crear solicitudes de recepción en la aplicación.", + }, + }, }, project_cycles: { add_cycle: "Agregar ciclo", diff --git a/packages/i18n/src/locales/fr/translations.ts b/packages/i18n/src/locales/fr/translations.ts index 79673abf0e..39feafad75 100644 --- a/packages/i18n/src/locales/fr/translations.ts +++ b/packages/i18n/src/locales/fr/translations.ts @@ -1997,6 +1997,46 @@ export default { primary_button: "Ajouter un système d’estimation", }, }, + features: { + cycles: { + title: "Cycles", + short_title: "Cycles", + description: + "Planifiez le travail dans des périodes flexibles qui s'adaptent au rythme et au tempo uniques de ce projet.", + toggle_title: "Activer les cycles", + toggle_description: "Planifiez le travail dans des périodes ciblées.", + }, + modules: { + title: "Modules", + short_title: "Modules", + description: "Organisez le travail en sous-projets avec des chefs de projet et des responsables dédiés.", + toggle_title: "Activer les modules", + toggle_description: "Les membres du projet pourront créer et modifier des modules.", + }, + views: { + title: "Vues", + short_title: "Vues", + description: + "Enregistrez des tris, des filtres et des options d'affichage personnalisés ou partagez-les avec votre équipe.", + toggle_title: "Activer les vues", + toggle_description: "Les membres du projet pourront créer et modifier des vues.", + }, + pages: { + title: "Pages", + short_title: "Pages", + description: "Créez et modifiez du contenu libre : notes, documents, n'importe quoi.", + toggle_title: "Activer les pages", + toggle_description: "Les membres du projet pourront créer et modifier des pages.", + }, + intake: { + title: "Réception", + short_title: "Réception", + description: + "Permettez aux non-membres de partager des bugs, des commentaires et des suggestions ; sans perturber votre flux de travail.", + toggle_title: "Activer la réception", + toggle_description: "Permettre aux membres du projet de créer des demandes de réception dans l'application.", + }, + }, }, project_cycles: { add_cycle: "Ajouter un cycle", diff --git a/packages/i18n/src/locales/id/translations.ts b/packages/i18n/src/locales/id/translations.ts index f49d4e8d25..f6edba7800 100644 --- a/packages/i18n/src/locales/id/translations.ts +++ b/packages/i18n/src/locales/id/translations.ts @@ -1982,6 +1982,44 @@ export default { primary_button: "Tambah sistem perkiraan", }, }, + features: { + cycles: { + title: "Siklus", + short_title: "Siklus", + description: + "Jadwalkan pekerjaan dalam periode fleksibel yang menyesuaikan dengan ritme dan tempo unik proyek ini.", + toggle_title: "Aktifkan siklus", + toggle_description: "Rencanakan pekerjaan dalam jangka waktu yang terfokus.", + }, + modules: { + title: "Modul", + short_title: "Modul", + description: "Atur pekerjaan menjadi sub-proyek dengan pemimpin dan penerima tugas khusus.", + toggle_title: "Aktifkan modul", + toggle_description: "Anggota proyek akan dapat membuat dan mengedit modul.", + }, + views: { + title: "Tampilan", + short_title: "Tampilan", + description: "Simpan pengurutan, filter, dan opsi tampilan kustom atau bagikan dengan tim Anda.", + toggle_title: "Aktifkan tampilan", + toggle_description: "Anggota proyek akan dapat membuat dan mengedit tampilan.", + }, + pages: { + title: "Halaman", + short_title: "Halaman", + description: "Buat dan edit konten bebas: catatan, dokumen, apa saja.", + toggle_title: "Aktifkan halaman", + toggle_description: "Anggota proyek akan dapat membuat dan mengedit halaman.", + }, + intake: { + title: "Penerimaan", + short_title: "Penerimaan", + description: "Biarkan non-anggota berbagi bug, umpan balik, dan saran; tanpa mengganggu alur kerja Anda.", + toggle_title: "Aktifkan penerimaan", + toggle_description: "Izinkan anggota proyek membuat permintaan penerimaan dalam aplikasi.", + }, + }, }, project_cycles: { add_cycle: "Tambah siklus", diff --git a/packages/i18n/src/locales/it/translations.ts b/packages/i18n/src/locales/it/translations.ts index 0506e30693..21d8dfb4f4 100644 --- a/packages/i18n/src/locales/it/translations.ts +++ b/packages/i18n/src/locales/it/translations.ts @@ -1986,6 +1986,46 @@ export default { primary_button: "Aggiungi sistema di stime", }, }, + features: { + cycles: { + title: "Cicli", + short_title: "Cicli", + description: + "Pianifica il lavoro in periodi flessibili che si adattano al ritmo e al tempo unici di questo progetto.", + toggle_title: "Abilita cicli", + toggle_description: "Pianifica il lavoro in periodi di tempo mirati.", + }, + modules: { + title: "Moduli", + short_title: "Moduli", + description: "Organizza il lavoro in sotto-progetti con responsabili e assegnatari dedicati.", + toggle_title: "Abilita moduli", + toggle_description: "I membri del progetto potranno creare e modificare moduli.", + }, + views: { + title: "Viste", + short_title: "Viste", + description: + "Salva ordinamenti, filtri e opzioni di visualizzazione personalizzati o condividili con il tuo team.", + toggle_title: "Abilita viste", + toggle_description: "I membri del progetto potranno creare e modificare viste.", + }, + pages: { + title: "Pagine", + short_title: "Pagine", + description: "Crea e modifica contenuti liberi: note, documenti, qualsiasi cosa.", + toggle_title: "Abilita pagine", + toggle_description: "I membri del progetto potranno creare e modificare pagine.", + }, + intake: { + title: "Ricezione", + short_title: "Ricezione", + description: + "Consenti ai non membri di condividere bug, feedback e suggerimenti; senza interrompere il tuo flusso di lavoro.", + toggle_title: "Abilita ricezione", + toggle_description: "Consenti ai membri del progetto di creare richieste di ricezione nell'app.", + }, + }, }, project_cycles: { add_cycle: "Aggiungi ciclo", diff --git a/packages/i18n/src/locales/ja/translations.ts b/packages/i18n/src/locales/ja/translations.ts index 80ae283c79..611218e414 100644 --- a/packages/i18n/src/locales/ja/translations.ts +++ b/packages/i18n/src/locales/ja/translations.ts @@ -1971,6 +1971,43 @@ export default { primary_button: "見積もりシステムを追加", }, }, + features: { + cycles: { + title: "サイクル", + short_title: "サイクル", + description: "このプロジェクト独自のリズムとペースに適応する柔軟な期間で作業をスケジュールします。", + toggle_title: "サイクルを有効にする", + toggle_description: "集中的な期間で作業を計画します。", + }, + modules: { + title: "モジュール", + short_title: "モジュール", + description: "専任のリーダーと担当者を持つサブプロジェクトに作業を整理します。", + toggle_title: "モジュールを有効にする", + toggle_description: "プロジェクトメンバーはモジュールを作成および編集できるようになります。", + }, + views: { + title: "ビュー", + short_title: "ビュー", + description: "カスタムソート、フィルター、表示オプションを保存したり、チームと共有したりします。", + toggle_title: "ビューを有効にする", + toggle_description: "プロジェクトメンバーはビューを作成および編集できるようになります。", + }, + pages: { + title: "ページ", + short_title: "ページ", + description: "自由形式のコンテンツを作成および編集します:メモ、ドキュメント、何でも。", + toggle_title: "ページを有効にする", + toggle_description: "プロジェクトメンバーはページを作成および編集できるようになります。", + }, + intake: { + title: "受付", + short_title: "受付", + description: "ワークフローを中断することなく、非メンバーがバグ、フィードバック、提案を共有できるようにします。", + toggle_title: "受付を有効にする", + toggle_description: "プロジェクトメンバーがアプリ内で受付リクエストを作成できるようにします。", + }, + }, }, project_cycles: { add_cycle: "サイクルを追加", diff --git a/packages/i18n/src/locales/ko/translations.ts b/packages/i18n/src/locales/ko/translations.ts index 11b6fa1c34..74dd37a3cb 100644 --- a/packages/i18n/src/locales/ko/translations.ts +++ b/packages/i18n/src/locales/ko/translations.ts @@ -1964,6 +1964,43 @@ export default { primary_button: "추정 시스템 추가", }, }, + features: { + cycles: { + title: "사이클", + short_title: "사이클", + description: "이 프로젝트의 고유한 리듬과 속도에 적응하는 유연한 기간으로 작업을 예약합니다.", + toggle_title: "사이클 활성화", + toggle_description: "집중된 기간에 작업을 계획합니다.", + }, + modules: { + title: "모듈", + short_title: "모듈", + description: "전담 리더와 담당자가 있는 하위 프로젝트로 작업을 구성합니다.", + toggle_title: "모듈 활성화", + toggle_description: "프로젝트 멤버가 모듈을 생성하고 편집할 수 있습니다.", + }, + views: { + title: "보기", + short_title: "보기", + description: "사용자 정의 정렬, 필터 및 표시 옵션을 저장하거나 팀과 공유합니다.", + toggle_title: "보기 활성화", + toggle_description: "프로젝트 멤버가 보기를 생성하고 편집할 수 있습니다.", + }, + pages: { + title: "페이지", + short_title: "페이지", + description: "자유 형식 콘텐츠를 생성하고 편집합니다: 메모, 문서, 무엇이든.", + toggle_title: "페이지 활성화", + toggle_description: "프로젝트 멤버가 페이지를 생성하고 편집할 수 있습니다.", + }, + intake: { + title: "접수", + short_title: "접수", + description: "워크플로를 방해하지 않고 비회원이 버그, 피드백 및 제안을 공유할 수 있도록 합니다.", + toggle_title: "접수 활성화", + toggle_description: "프로젝트 멤버가 앱 내에서 접수 요청을 생성할 수 있도록 허용합니다.", + }, + }, }, project_cycles: { add_cycle: "주기 추가", diff --git a/packages/i18n/src/locales/pl/translations.ts b/packages/i18n/src/locales/pl/translations.ts index be091cbcf5..b81dd7676e 100644 --- a/packages/i18n/src/locales/pl/translations.ts +++ b/packages/i18n/src/locales/pl/translations.ts @@ -1969,6 +1969,45 @@ export default { primary_button: "Dodaj system szacowania", }, }, + features: { + cycles: { + title: "Cykle", + short_title: "Cykle", + description: + "Planuj pracę w elastycznych okresach, które dostosowują się do unikalnego rytmu i tempa tego projektu.", + toggle_title: "Włącz cykle", + toggle_description: "Planuj pracę w skoncentrowanych ramach czasowych.", + }, + modules: { + title: "Moduły", + short_title: "Moduły", + description: "Organizuj pracę w podprojekty z dedykowanymi liderami i przypisanymi osobami.", + toggle_title: "Włącz moduły", + toggle_description: "Członkowie projektu będą mogli tworzyć i edytować moduły.", + }, + views: { + title: "Widoki", + short_title: "Widoki", + description: "Zapisuj niestandardowe sortowania, filtry i opcje wyświetlania lub udostępniaj je zespołowi.", + toggle_title: "Włącz widoki", + toggle_description: "Członkowie projektu będą mogli tworzyć i edytować widoki.", + }, + pages: { + title: "Strony", + short_title: "Strony", + description: "Twórz i edytuj dowolne treści: notatki, dokumenty, cokolwiek.", + toggle_title: "Włącz strony", + toggle_description: "Członkowie projektu będą mogli tworzyć i edytować strony.", + }, + intake: { + title: "Odbiór", + short_title: "Odbiór", + description: + "Pozwól osobom niebędącym członkami dzielić się błędami, opiniami i sugestiami; bez zakłócania przepływu pracy.", + toggle_title: "Włącz odbiór", + toggle_description: "Pozwól członkom projektu tworzyć żądania odbioru w aplikacji.", + }, + }, }, project_cycles: { add_cycle: "Dodaj cykl", diff --git a/packages/i18n/src/locales/pt-BR/translations.ts b/packages/i18n/src/locales/pt-BR/translations.ts index d926cdbe18..1424a438f7 100644 --- a/packages/i18n/src/locales/pt-BR/translations.ts +++ b/packages/i18n/src/locales/pt-BR/translations.ts @@ -1995,6 +1995,44 @@ export default { primary_button: "Adicionar sistema de estimativa", }, }, + features: { + cycles: { + title: "Ciclos", + short_title: "Ciclos", + description: "Agende o trabalho em períodos flexíveis que se adaptam ao ritmo e ao tempo únicos deste projeto.", + toggle_title: "Ativar ciclos", + toggle_description: "Planeje o trabalho em períodos de tempo focados.", + }, + modules: { + title: "Módulos", + short_title: "Módulos", + description: "Organize o trabalho em subprojetos com líderes e responsáveis dedicados.", + toggle_title: "Ativar módulos", + toggle_description: "Os membros do projeto poderão criar e editar módulos.", + }, + views: { + title: "Visualizações", + short_title: "Visualizações", + description: "Salve ordenações, filtros e opções de exibição personalizadas ou compartilhe-as com sua equipe.", + toggle_title: "Ativar visualizações", + toggle_description: "Os membros do projeto poderão criar e editar visualizações.", + }, + pages: { + title: "Páginas", + short_title: "Páginas", + description: "Crie e edite conteúdo livre: notas, documentos, qualquer coisa.", + toggle_title: "Ativar páginas", + toggle_description: "Os membros do projeto poderão criar e editar páginas.", + }, + intake: { + title: "Recepção", + short_title: "Recepção", + description: + "Permita que não membros compartilhem bugs, feedback e sugestões; sem interromper seu fluxo de trabalho.", + toggle_title: "Ativar recepção", + toggle_description: "Permitir que membros do projeto criem solicitações de recepção no aplicativo.", + }, + }, }, project_cycles: { add_cycle: "Adicionar ciclo", diff --git a/packages/i18n/src/locales/ro/translations.ts b/packages/i18n/src/locales/ro/translations.ts index fc4f043024..c9c748f7ff 100644 --- a/packages/i18n/src/locales/ro/translations.ts +++ b/packages/i18n/src/locales/ro/translations.ts @@ -1987,6 +1987,45 @@ export default { primary_button: "Adaugă sistem de estimare", }, }, + features: { + cycles: { + title: "Cicluri", + short_title: "Cicluri", + description: + "Programați munca în perioade flexibile care se adaptează ritmului și ritmului unic al acestui proiect.", + toggle_title: "Activați ciclurile", + toggle_description: "Planificați munca în intervale de timp concentrate.", + }, + modules: { + title: "Module", + short_title: "Module", + description: "Organizați munca în subproiecte cu lideri și responsabili dedicați.", + toggle_title: "Activați modulele", + toggle_description: "Membrii proiectului vor putea crea și edita module.", + }, + views: { + title: "Vizualizări", + short_title: "Vizualizări", + description: "Salvați sortări personalizate, filtre și opțiuni de afișare sau partajați-le cu echipa dvs.", + toggle_title: "Activați vizualizările", + toggle_description: "Membrii proiectului vor putea crea și edita vizualizări.", + }, + pages: { + title: "Pagini", + short_title: "Pagini", + description: "Creați și editați conținut liber: note, documente, orice.", + toggle_title: "Activați paginile", + toggle_description: "Membrii proiectului vor putea crea și edita pagini.", + }, + intake: { + title: "Recepție", + short_title: "Recepție", + description: + "Permiteți non-membrilor să partajeze erori, feedback și sugestii; fără a perturba fluxul de lucru.", + toggle_title: "Activați recepția", + toggle_description: "Permiteți membrilor proiectului să creeze solicitări de recepție în aplicație.", + }, + }, }, project_cycles: { add_cycle: "Adaugă ciclu", diff --git a/packages/i18n/src/locales/ru/translations.ts b/packages/i18n/src/locales/ru/translations.ts index ae823d17fe..f91403e914 100644 --- a/packages/i18n/src/locales/ru/translations.ts +++ b/packages/i18n/src/locales/ru/translations.ts @@ -1973,6 +1973,46 @@ export default { primary_button: "Добавить систему оценок", }, }, + features: { + cycles: { + title: "Циклы", + short_title: "Циклы", + description: + "Планируйте работу в гибких периодах, которые адаптируются к уникальному ритму и темпу этого проекта.", + toggle_title: "Включить циклы", + toggle_description: "Планируйте работу в целенаправленные периоды времени.", + }, + modules: { + title: "Модули", + short_title: "Модули", + description: "Организуйте работу в подпроекты с выделенными руководителями и исполнителями.", + toggle_title: "Включить модули", + toggle_description: "Участники проекта смогут создавать и редактировать модули.", + }, + views: { + title: "Представления", + short_title: "Представления", + description: + "Сохраняйте пользовательские сортировки, фильтры и параметры отображения или делитесь ими с командой.", + toggle_title: "Включить представления", + toggle_description: "Участники проекта смогут создавать и редактировать представления.", + }, + pages: { + title: "Страницы", + short_title: "Страницы", + description: "Создавайте и редактируйте свободный контент: заметки, документы, что угодно.", + toggle_title: "Включить страницы", + toggle_description: "Участники проекта смогут создавать и редактировать страницы.", + }, + intake: { + title: "Приём", + short_title: "Приём", + description: + "Позвольте не-участникам делиться ошибками, отзывами и предложениями; не нарушая ваш рабочий процесс.", + toggle_title: "Включить приём", + toggle_description: "Разрешить участникам проекта создавать запросы на приём в приложении.", + }, + }, }, project_cycles: { add_cycle: "Добавить цикл", diff --git a/packages/i18n/src/locales/sk/translations.ts b/packages/i18n/src/locales/sk/translations.ts index fb295de3f4..5e579e1d5f 100644 --- a/packages/i18n/src/locales/sk/translations.ts +++ b/packages/i18n/src/locales/sk/translations.ts @@ -1967,6 +1967,44 @@ export default { primary_button: "Pridať systém odhadov", }, }, + features: { + cycles: { + title: "Cykly", + short_title: "Cykly", + description: + "Naplánujte prácu v flexibilných obdobiach, ktoré sa prispôsobia jedinečnému rytmu a tempu tohto projektu.", + toggle_title: "Povoliť cykly", + toggle_description: "Naplánujte prácu v sústredenej časovej osi.", + }, + modules: { + title: "Moduly", + short_title: "Moduly", + description: "Organizujte prácu do podprojektov s vyčlenenými vedúcimi a priradenými osobami.", + toggle_title: "Povoliť moduly", + toggle_description: "Členovia projektu budú môcť vytvárať a upravovať moduly.", + }, + views: { + title: "Zobrazenia", + short_title: "Zobrazenia", + description: "Uložte vlastné triedenia, filtre a možnosti zobrazenia alebo ich zdieľajte so svojím tímom.", + toggle_title: "Povoliť zobrazenia", + toggle_description: "Členovia projektu budú môcť vytvárať a upravovať zobrazenia.", + }, + pages: { + title: "Stránky", + short_title: "Stránky", + description: "Vytvárajte a upravujte voľný obsah: poznámky, dokumenty, čokoľvek.", + toggle_title: "Povoliť stránky", + toggle_description: "Členovia projektu budú môcť vytvárať a upravovať stránky.", + }, + intake: { + title: "Príjem", + short_title: "Príjem", + description: "Umožnite nečlenom zdieľať chyby, spätnú väzbu a návrhy; bez narušenia vášho pracovného postupu.", + toggle_title: "Povoliť príjem", + toggle_description: "Povoliť členom projektu vytvárať žiadosti o príjem v aplikácii.", + }, + }, }, project_cycles: { add_cycle: "Pridať cyklus", diff --git a/packages/i18n/src/locales/tr-TR/translations.ts b/packages/i18n/src/locales/tr-TR/translations.ts index f7d987abb3..568856113a 100644 --- a/packages/i18n/src/locales/tr-TR/translations.ts +++ b/packages/i18n/src/locales/tr-TR/translations.ts @@ -1956,6 +1956,44 @@ export default { primary_button: "Tahmin sistemi ekle", }, }, + features: { + cycles: { + title: "Döngüler", + short_title: "Döngüler", + description: "Bu projenin benzersiz ritmine ve hızına uyum sağlayan esnek dönemlerde iş planlayın.", + toggle_title: "Döngüleri etkinleştir", + toggle_description: "Odaklanmış zaman dilimlerinde iş planlayın.", + }, + modules: { + title: "Modüller", + short_title: "Modüller", + description: "İşi özel liderler ve atananlarla alt projelere organize edin.", + toggle_title: "Modülleri etkinleştir", + toggle_description: "Proje üyeleri modüller oluşturabilir ve düzenleyebilir.", + }, + views: { + title: "Görünümler", + short_title: "Görünümler", + description: "Özel sıralamalar, filtreler ve görüntüleme seçeneklerini kaydedin veya ekibinizle paylaşın.", + toggle_title: "Görünümleri etkinleştir", + toggle_description: "Proje üyeleri görünümler oluşturabilir ve düzenleyebilir.", + }, + pages: { + title: "Sayfalar", + short_title: "Sayfalar", + description: "Serbest biçimli içerik oluşturun ve düzenleyin: notlar, belgeler, herhangi bir şey.", + toggle_title: "Sayfaları etkinleştir", + toggle_description: "Proje üyeleri sayfalar oluşturabilir ve düzenleyebilir.", + }, + intake: { + title: "Alım", + short_title: "Alım", + description: + "Üye olmayanların hataları, geri bildirimleri ve önerileri paylaşmasına izin verin; iş akışınızı aksatmadan.", + toggle_title: "Alımı etkinleştir", + toggle_description: "Proje üyelerinin uygulama içinde alım talepleri oluşturmasına izin verin.", + }, + }, }, project_cycles: { add_cycle: "Döngü ekle", diff --git a/packages/i18n/src/locales/ua/translations.ts b/packages/i18n/src/locales/ua/translations.ts index 04878fa054..2a56124272 100644 --- a/packages/i18n/src/locales/ua/translations.ts +++ b/packages/i18n/src/locales/ua/translations.ts @@ -1972,6 +1972,45 @@ export default { primary_button: "Додати систему оцінок", }, }, + features: { + cycles: { + title: "Цикли", + short_title: "Цикли", + description: "Плануйте роботу в гнучких періодах, які адаптуються до унікального ритму та темпу цього проекту.", + toggle_title: "Увімкнути цикли", + toggle_description: "Плануйте роботу в цілеспрямовані періоди часу.", + }, + modules: { + title: "Модулі", + short_title: "Модулі", + description: "Організуйте роботу в підпроекти з виділеними керівниками та виконавцями.", + toggle_title: "Увімкнути модулі", + toggle_description: "Учасники проекту зможуть створювати та редагувати модулі.", + }, + views: { + title: "Перегляди", + short_title: "Перегляди", + description: + "Зберігайте користувацькі сортування, фільтри та параметри відображення або діліться ними з командою.", + toggle_title: "Увімкнути перегляди", + toggle_description: "Учасники проекту зможуть створювати та редагувати перегляди.", + }, + pages: { + title: "Сторінки", + short_title: "Сторінки", + description: "Створюйте та редагуйте вільний контент: нотатки, документи, що завгодно.", + toggle_title: "Увімкнути сторінки", + toggle_description: "Учасники проекту зможуть створювати та редагувати сторінки.", + }, + intake: { + title: "Прийом", + short_title: "Прийом", + description: + "Дозвольте не-учасникам ділитися помилками, відгуками та пропозиціями; не порушуючи ваш робочий процес.", + toggle_title: "Увімкнути прийом", + toggle_description: "Дозволити учасникам проекту створювати запити на прийом в додатку.", + }, + }, }, project_cycles: { add_cycle: "Додати цикл", diff --git a/packages/i18n/src/locales/vi-VN/translations.ts b/packages/i18n/src/locales/vi-VN/translations.ts index c913d25b09..70a7cccded 100644 --- a/packages/i18n/src/locales/vi-VN/translations.ts +++ b/packages/i18n/src/locales/vi-VN/translations.ts @@ -1980,6 +1980,45 @@ export default { primary_button: "Thêm hệ thống ước tính", }, }, + features: { + cycles: { + title: "Chu kỳ", + short_title: "Chu kỳ", + description: + "Lên lịch công việc trong các khoảng thời gian linh hoạt thích ứng với nhịp điệu và tốc độ độc đáo của dự án này.", + toggle_title: "Bật chu kỳ", + toggle_description: "Lập kế hoạch công việc trong khung thời gian tập trung.", + }, + modules: { + title: "Mô-đun", + short_title: "Mô-đun", + description: "Tổ chức công việc thành các dự án phụ với người dẫn đầu và người được phân công chuyên trách.", + toggle_title: "Bật mô-đun", + toggle_description: "Thành viên dự án sẽ có thể tạo và chỉnh sửa mô-đun.", + }, + views: { + title: "Chế độ xem", + short_title: "Chế độ xem", + description: "Lưu các tùy chọn sắp xếp, bộ lọc và hiển thị tùy chỉnh hoặc chia sẻ chúng với nhóm của bạn.", + toggle_title: "Bật chế độ xem", + toggle_description: "Thành viên dự án sẽ có thể tạo và chỉnh sửa chế độ xem.", + }, + pages: { + title: "Trang", + short_title: "Trang", + description: "Tạo và chỉnh sửa nội dung tự do: ghi chú, tài liệu, bất cứ thứ gì.", + toggle_title: "Bật trang", + toggle_description: "Thành viên dự án sẽ có thể tạo và chỉnh sửa trang.", + }, + intake: { + title: "Tiếp nhận", + short_title: "Tiếp nhận", + description: + "Cho phép những người không phải thành viên chia sẻ lỗi, phản hồi và đề xuất; mà không làm gián đoạn quy trình làm việc của bạn.", + toggle_title: "Bật tiếp nhận", + toggle_description: "Cho phép thành viên dự án tạo yêu cầu tiếp nhận trong ứng dụng.", + }, + }, }, project_cycles: { add_cycle: "Thêm chu kỳ", diff --git a/packages/i18n/src/locales/zh-CN/translations.ts b/packages/i18n/src/locales/zh-CN/translations.ts index 4d2a520c2e..cca65f5bb5 100644 --- a/packages/i18n/src/locales/zh-CN/translations.ts +++ b/packages/i18n/src/locales/zh-CN/translations.ts @@ -1929,6 +1929,43 @@ export default { primary_button: "添加估算系统", }, }, + features: { + cycles: { + title: "周期", + short_title: "周期", + description: "在灵活的时间段内安排工作,以适应该项目独特的节奏和步调。", + toggle_title: "启用周期", + toggle_description: "在集中的时间段内规划工作。", + }, + modules: { + title: "模块", + short_title: "模块", + description: "将工作组织成具有专门负责人和受让人的子项目。", + toggle_title: "启用模块", + toggle_description: "项目成员将能够创建和编辑模块。", + }, + views: { + title: "视图", + short_title: "视图", + description: "保存自定义排序、过滤器和显示选项,或与团队共享。", + toggle_title: "启用视图", + toggle_description: "项目成员将能够创建和编辑视图。", + }, + pages: { + title: "页面", + short_title: "页面", + description: "创建和编辑自由格式的内容:笔记、文档、任何内容。", + toggle_title: "启用页面", + toggle_description: "项目成员将能够创建和编辑页面。", + }, + intake: { + title: "接收", + short_title: "接收", + description: "让非成员分享错误、反馈和建议;而不会中断您的工作流程。", + toggle_title: "启用接收", + toggle_description: "允许项目成员在应用中创建接收请求。", + }, + }, }, project_cycles: { add_cycle: "添加周期", diff --git a/packages/i18n/src/locales/zh-TW/translations.ts b/packages/i18n/src/locales/zh-TW/translations.ts index d63b1d06ee..337b329c76 100644 --- a/packages/i18n/src/locales/zh-TW/translations.ts +++ b/packages/i18n/src/locales/zh-TW/translations.ts @@ -1949,6 +1949,43 @@ export default { primary_button: "新增評估系統", }, }, + features: { + cycles: { + title: "週期", + short_title: "週期", + description: "在靈活的時間段內安排工作,以適應該專案獨特的節奏和步調。", + toggle_title: "啟用週期", + toggle_description: "在集中的時間段內規劃工作。", + }, + modules: { + title: "模組", + short_title: "模組", + description: "將工作組織成具有專門負責人和受讓人的子專案。", + toggle_title: "啟用模組", + toggle_description: "專案成員將能夠建立和編輯模組。", + }, + views: { + title: "檢視", + short_title: "檢視", + description: "儲存自訂排序、篩選器和顯示選項,或與團隊共享。", + toggle_title: "啟用檢視", + toggle_description: "專案成員將能夠建立和編輯檢視。", + }, + pages: { + title: "頁面", + short_title: "頁面", + description: "建立和編輯自由格式的內容:筆記、文件、任何內容。", + toggle_title: "啟用頁面", + toggle_description: "專案成員將能夠建立和編輯頁面。", + }, + intake: { + title: "接收", + short_title: "接收", + description: "讓非成員分享錯誤、回饋和建議;而不會中斷您的工作流程。", + toggle_title: "啟用接收", + toggle_description: "允許專案成員在應用程式中建立接收請求。", + }, + }, }, project_cycles: { add_cycle: "新增週期", diff --git a/packages/tailwind-config/animations.css b/packages/tailwind-config/animations.css index 3c846184c2..04835346c9 100644 --- a/packages/tailwind-config/animations.css +++ b/packages/tailwind-config/animations.css @@ -74,4 +74,15 @@ opacity: 0; } } + + /* fade in */ + --animate-fade-in: fadeIn 0.25s ease-out forwards; + @keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9d3103c83b..7b0df5b6e8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -36,6 +36,7 @@ export * from "./reaction"; export * from "./intake"; export * from "./rich-filters"; export * from "./search"; +export * from "./settings"; export * from "./state"; export * from "./stickies"; export * from "./timezone"; diff --git a/packages/types/src/settings.ts b/packages/types/src/settings.ts new file mode 100644 index 0000000000..b7c954a57d --- /dev/null +++ b/packages/types/src/settings.ts @@ -0,0 +1,34 @@ +// local imports +import type { EUserProjectRoles } from "."; +import type { EUserWorkspaceRoles } from "./workspace"; + +export type TProfileSettingsTabs = "general" | "preferences" | "activity" | "notifications" | "security" | "api-tokens"; + +export type TWorkspaceSettingsTabs = "general" | "members" | "billing-and-plans" | "export" | "webhooks"; +export type TWorkspaceSettingsItem = { + key: TWorkspaceSettingsTabs; + i18n_label: string; + href: string; + access: EUserWorkspaceRoles[]; + highlight: (pathname: string, baseUrl: string) => boolean; +}; + +export type TProjectSettingsTabs = + | "general" + | "members" + | "features_cycles" + | "features_modules" + | "features_views" + | "features_pages" + | "features_intake" + | "states" + | "labels" + | "estimates" + | "automations"; +export type TProjectSettingsItem = { + key: TProjectSettingsTabs; + i18n_label: string; + href: string; + access: EUserProjectRoles[]; + highlight: (pathname: string, baseUrl: string) => boolean; +}; diff --git a/packages/ui/src/tables/table.tsx b/packages/ui/src/tables/table.tsx index 956af8435e..1f798f32ba 100644 --- a/packages/ui/src/tables/table.tsx +++ b/packages/ui/src/tables/table.tsx @@ -20,8 +20,8 @@ export function Table(props: TTableData) { return ( - - + + {columns.map((column) => ( - + {data.map((item) => ( {columns.map((column) => ( {canLoadMoreIssues && ( - + {Array.from({ length: 3 }).map((_, index) => ( + + ))} )}
{(column?.thRender && column?.thRender()) || column.content} @@ -29,11 +29,11 @@ export function Table(props: TTableData) { ))}
From 57806f9bd50bba3659466eae6edf3ff9adaced89 Mon Sep 17 00:00:00 2001 From: punto <119956578+AshrithSathu@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:38:47 +0530 Subject: [PATCH 15/63] [GIT-45] fix: allow markdown file attachments (#8524) * fix: allow markdown file attachments - Add text/markdown to ATTACHMENT_MIME_TYPES - Fixes issue where .md files were rejected with 'Invalid file type' error * added the support for frontend mime type too --- apps/api/plane/settings/common.py | 1 + packages/editor/src/core/constants/config.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 8b94ee44e3..0ef605ba1f 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -380,6 +380,7 @@ ATTACHMENT_MIME_TYPES = [ "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/plain", + "text/markdown", "application/rtf", "application/vnd.oasis.opendocument.spreadsheet", "application/vnd.oasis.opendocument.text", diff --git a/packages/editor/src/core/constants/config.ts b/packages/editor/src/core/constants/config.ts index cdca814592..1a9b15a953 100644 --- a/packages/editor/src/core/constants/config.ts +++ b/packages/editor/src/core/constants/config.ts @@ -26,6 +26,7 @@ export const ACCEPTED_ATTACHMENT_MIME_TYPES = [ "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/plain", + "text/markdown", "application/rtf", "audio/mpeg", "audio/wav", From 20e266c9bbfab7bf077778d065fe68c9c1b61c3d Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:47:49 +0530 Subject: [PATCH 16/63] fix: node view renders (#8559) * fix node renders * fix handlers * fix: duplicate id --- .../src/core/extensions/callout/block.tsx | 3 +- .../src/core/extensions/callout/types.ts | 2 + .../src/core/extensions/callout/utils.ts | 3 +- .../extensions/code/code-block-node-view.tsx | 9 +- .../editor/src/core/extensions/code/types.ts | 9 ++ .../custom-image/components/block.tsx | 82 +++++++++++-------- .../custom-image/components/node-view.tsx | 14 ++-- .../extensions/mentions/mention-node-view.tsx | 2 +- .../extensions/work-item-embed/extension.tsx | 23 ++++-- .../core/extensions/work-item-embed/types.ts | 15 ++++ 10 files changed, 106 insertions(+), 56 deletions(-) create mode 100644 packages/editor/src/core/extensions/code/types.ts create mode 100644 packages/editor/src/core/extensions/work-item-embed/types.ts diff --git a/packages/editor/src/core/extensions/callout/block.tsx b/packages/editor/src/core/extensions/callout/block.tsx index 94fa5a8113..e6a372a4d1 100644 --- a/packages/editor/src/core/extensions/callout/block.tsx +++ b/packages/editor/src/core/extensions/callout/block.tsx @@ -29,13 +29,14 @@ export function CustomCalloutBlock(props: CustomCalloutNodeViewProps) { return ( { if (storedData) { let parsedData: TLogoProps; try { - parsedData = JSON.parse(storedData); + parsedData = JSON.parse(storedData) as TLogoProps; } catch (error) { console.error(`Error parsing stored callout logo, stored value- ${storedData}`, error); localStorage.removeItem("editor-calloutComponent-logo"); diff --git a/packages/editor/src/core/extensions/code/code-block-node-view.tsx b/packages/editor/src/core/extensions/code/code-block-node-view.tsx index 5060a474ff..51d7438683 100644 --- a/packages/editor/src/core/extensions/code/code-block-node-view.tsx +++ b/packages/editor/src/core/extensions/code/code-block-node-view.tsx @@ -9,6 +9,9 @@ import { CopyIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; // plane utils import { cn } from "@plane/utils"; +// types +import type { TCodeBlockAttributes } from "./types"; +import { ECodeBlockAttributeNames } from "./types"; // we just have ts support for now const lowlight = createLowlight(common); @@ -20,6 +23,8 @@ type Props = { export function CodeBlockComponent({ node }: Props) { const [copied, setCopied] = useState(false); + // derived values + const attrs = node.attrs as TCodeBlockAttributes; const copyToClipboard = async (e: React.MouseEvent) => { try { @@ -34,7 +39,7 @@ export function CodeBlockComponent({ node }: Props) { }; return ( - +
diff --git a/apps/web/core/components/ui/loader/layouts/kanban-layout-loader.tsx b/apps/web/core/components/ui/loader/layouts/kanban-layout-loader.tsx index 6e4e7347b2..0769cfbd58 100644 --- a/apps/web/core/components/ui/loader/layouts/kanban-layout-loader.tsx +++ b/apps/web/core/components/ui/loader/layouts/kanban-layout-loader.tsx @@ -18,7 +18,7 @@ export const KanbanIssueBlockLoader = forwardRef(function KanbanIssueBlockLoader return ( ); diff --git a/apps/web/core/components/ui/loader/layouts/list-layout-loader.tsx b/apps/web/core/components/ui/loader/layouts/list-layout-loader.tsx index 12ea14115e..addfd582fe 100644 --- a/apps/web/core/components/ui/loader/layouts/list-layout-loader.tsx +++ b/apps/web/core/components/ui/loader/layouts/list-layout-loader.tsx @@ -23,23 +23,26 @@ export const ListLoaderItemRow = forwardRef(function ListLoaderItemRow( return (
@@ -48,14 +51,14 @@ export const ListLoaderItemRow = forwardRef(function ListLoaderItemRow( {getRandomInt(1, 2) % 2 === 0 ? ( ) : ( Date: Fri, 13 Feb 2026 18:50:18 +0530 Subject: [PATCH 49/63] [WEB-1201] chore: dropdown options hierarchy improvements (#8501) * chore: sortBySelectedFirst and sortByCurrentUserThenSelected utils added * chore: members dropdown updated * chore: module dropdown updated * chore: project and label dropdown updated * chore: code refactor --- .../core/components/dropdowns/member/base.tsx | 1 + .../dropdowns/member/member-options.tsx | 11 ++- .../core/components/dropdowns/module/base.tsx | 1 + .../dropdowns/module/module-options.tsx | 11 ++- .../components/dropdowns/project/base.tsx | 11 ++- .../properties/label-dropdown.tsx | 10 ++- packages/utils/src/array.ts | 76 +++++++++++++++++++ 7 files changed, 107 insertions(+), 14 deletions(-) diff --git a/apps/web/core/components/dropdowns/member/base.tsx b/apps/web/core/components/dropdowns/member/base.tsx index 2583c93cb3..32c035d95b 100644 --- a/apps/web/core/components/dropdowns/member/base.tsx +++ b/apps/web/core/components/dropdowns/member/base.tsx @@ -183,6 +183,7 @@ export const MemberDropdownBase = observer(function MemberDropdownBase(props: TM optionsClassName={optionsClassName} placement={placement} referenceElement={referenceElement} + value={value} /> )} diff --git a/apps/web/core/components/dropdowns/member/member-options.tsx b/apps/web/core/components/dropdowns/member/member-options.tsx index a18a8f8310..c3fd99405f 100644 --- a/apps/web/core/components/dropdowns/member/member-options.tsx +++ b/apps/web/core/components/dropdowns/member/member-options.tsx @@ -17,7 +17,7 @@ import { CheckIcon, SearchIcon, SuspendedUserIcon } from "@plane/propel/icons"; import { EPillSize, EPillVariant, Pill } from "@plane/propel/pill"; import type { IUserLite } from "@plane/types"; import { Avatar } from "@plane/ui"; -import { cn, getFileURL } from "@plane/utils"; +import { cn, getFileURL, sortByCurrentUserThenSelected } from "@plane/utils"; // hooks import { useMember } from "@/hooks/store/use-member"; import { useUser } from "@/hooks/store/user"; @@ -32,6 +32,7 @@ interface Props { optionsClassName?: string; placement: Placement | undefined; referenceElement: HTMLButtonElement | null; + value?: string[] | string | null; } export const MemberOptions = observer(function MemberOptions(props: Props) { @@ -43,6 +44,7 @@ export const MemberOptions = observer(function MemberOptions(props: Props) { optionsClassName = "", placement, referenceElement, + value, } = props; // router const { workspaceSlug } = useParams(); @@ -117,8 +119,11 @@ export const MemberOptions = observer(function MemberOptions(props: Props) { }) .filter((o) => !!o); - const filteredOptions = - query === "" ? options : options?.filter((o) => o?.query.toLowerCase().includes(query.toLowerCase())); + const filteredOptions = sortByCurrentUserThenSelected( + query === "" ? options : options?.filter((o) => o?.query.toLowerCase().includes(query.toLowerCase())), + value, + currentUser?.id + ); return createPortal( diff --git a/apps/web/core/components/dropdowns/module/base.tsx b/apps/web/core/components/dropdowns/module/base.tsx index 1343858963..6487e57ba7 100644 --- a/apps/web/core/components/dropdowns/module/base.tsx +++ b/apps/web/core/components/dropdowns/module/base.tsx @@ -193,6 +193,7 @@ export const ModuleDropdownBase = observer(function ModuleDropdownBase(props: TM multiple={multiple} getModuleById={getModuleById} moduleIds={moduleIds} + value={value} /> )} diff --git a/apps/web/core/components/dropdowns/module/module-options.tsx b/apps/web/core/components/dropdowns/module/module-options.tsx index 6db54330d4..263906f045 100644 --- a/apps/web/core/components/dropdowns/module/module-options.tsx +++ b/apps/web/core/components/dropdowns/module/module-options.tsx @@ -13,7 +13,7 @@ import { Combobox } from "@headlessui/react"; import { useTranslation } from "@plane/i18n"; import { CheckIcon, SearchIcon, ModuleIcon } from "@plane/propel/icons"; import type { IModule } from "@plane/types"; -import { cn } from "@plane/utils"; +import { cn, sortBySelectedFirst } from "@plane/utils"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -33,10 +33,11 @@ interface Props { onDropdownOpen?: () => void; placement: Placement | undefined; referenceElement: HTMLButtonElement | null; + value?: string[] | string | null; } export const ModuleOptions = observer(function ModuleOptions(props: Props) { - const { getModuleById, isOpen, moduleIds, multiple, onDropdownOpen, placement, referenceElement } = props; + const { getModuleById, isOpen, moduleIds, multiple, onDropdownOpen, placement, referenceElement, value } = props; // refs const inputRef = useRef(null); // states @@ -106,8 +107,10 @@ export const ModuleOptions = observer(function ModuleOptions(props: Props) { ), }); - const filteredOptions = - query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); + const filteredOptions = sortBySelectedFirst( + query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())), + value + ); return ( diff --git a/apps/web/core/components/dropdowns/project/base.tsx b/apps/web/core/components/dropdowns/project/base.tsx index c551209f12..df6eabef04 100644 --- a/apps/web/core/components/dropdowns/project/base.tsx +++ b/apps/web/core/components/dropdowns/project/base.tsx @@ -14,7 +14,7 @@ import { useTranslation } from "@plane/i18n"; import { Logo } from "@plane/propel/emoji-icon-picker"; import { CheckIcon, SearchIcon, ProjectIcon, ChevronDownIcon } from "@plane/propel/icons"; import { ComboDropDown } from "@plane/ui"; -import { cn } from "@plane/utils"; +import { cn, sortBySelectedFirst } from "@plane/utils"; // components // hooks import { useDropdown } from "@/hooks/use-dropdown"; @@ -116,10 +116,13 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props: }; }); - const filteredOptions = - query === "" + const filteredOptions = sortBySelectedFirst( + (query === "" ? options?.filter((o) => o?.value !== currentProjectId) - : options?.filter((o) => o?.value !== currentProjectId && o?.query.toLowerCase().includes(query.toLowerCase())); + : options?.filter((o) => o?.value !== currentProjectId && o?.query.toLowerCase().includes(query.toLowerCase())) + )?.filter((o): o is NonNullable => o !== undefined), + value + ); const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({ dropdownRef, diff --git a/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx b/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx index 983c7ee67f..d1f166d969 100644 --- a/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx +++ b/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx @@ -20,6 +20,7 @@ import type { IIssueLabel } from "@plane/types"; import { EUserProjectRoles } from "@plane/types"; // components import { ComboDropDown } from "@plane/ui"; +import { sortBySelectedFirst } from "@plane/utils"; // hooks import { useLabel } from "@/hooks/store/use-label"; import { useUserPermissions } from "@/hooks/store/user"; @@ -118,8 +119,11 @@ export function LabelDropdown(props: ILabelDropdownProps) { const filteredOptions = useMemo( () => - query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())), - [options, query] + sortBySelectedFirst( + query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())), + value + ), + [options, query, value] ); const { styles, attributes } = usePopper(referenceElement, popperElement, { @@ -270,7 +274,7 @@ export function LabelDropdown(props: ILabelDropdownProps) {
{isLoading ? (

{t("common.loading")}

- ) : filteredOptions.length > 0 ? ( + ) : filteredOptions && filteredOptions.length > 0 ? ( filteredOptions.map((option) => ( { return obj; }; + +/** + * @description Sorts dropdown options with selected items appearing first + * @param {T[]} options Array of dropdown options with value property + * @param {string[] | string | null | undefined} selectedValues Selected value(s) - array for multi-select, string for single-select + * @returns {T[]} Sorted array with selected items first + * @example + * const options = [{value: '1', label: 'A'}, {value: '2', label: 'B'}]; + * sortBySelectedFirst(options, ['2']) // returns [{value: '2', label: 'B'}, {value: '1', label: 'A'}] + */ +export const sortBySelectedFirst = ( + options: T[] | undefined, + selectedValues: string[] | string | null | undefined +): T[] | undefined => { + if (!options || options.length === 0) return options; + + // Normalize selectedValues to array for consistent handling + const selectedSet = new Set(Array.isArray(selectedValues) ? selectedValues : selectedValues ? [selectedValues] : []); + + if (selectedSet.size === 0) return options; + + // Create a shallow copy to avoid mutating the original array + return [...options].sort((a, b) => { + const aSelected = a.value !== null && selectedSet.has(a.value); + const bSelected = b.value !== null && selectedSet.has(b.value); + + // If both selected or both unselected, maintain original order + if (aSelected === bSelected) return 0; + + // Selected items come first + return aSelected ? -1 : 1; + }); +}; + +/** + * @description Sorts dropdown options with current user first, then selected items, then unselected items + * @param {T[]} options Array of dropdown options with value property + * @param {string[] | string | null | undefined} selectedValues Selected value(s) - array for multi-select, string for single-select + * @param {string | undefined} currentUserId ID of the current user to prioritize + * @returns {T[]} Sorted array with current user first, then selected items, then unselected + * @example + * const options = [{value: 'user1'}, {value: 'user2'}, {value: 'user3'}]; + * sortByCurrentUserThenSelected(options, ['user2'], 'user3') + * // returns [{value: 'user3'}, {value: 'user2'}, {value: 'user1'}] + */ +export const sortByCurrentUserThenSelected = ( + options: T[] | undefined, + selectedValues: string[] | string | null | undefined, + currentUserId: string | undefined +): T[] | undefined => { + if (!options || options.length === 0) return options; + + // Normalize selectedValues to array for consistent handling + const selectedSet = new Set(Array.isArray(selectedValues) ? selectedValues : selectedValues ? [selectedValues] : []); + + // Create a shallow copy to avoid mutating the original array + return [...options].sort((a, b) => { + const aIsCurrent = currentUserId && a.value === currentUserId; + const bIsCurrent = currentUserId && b.value === currentUserId; + + // Current user always comes first + if (aIsCurrent && !bIsCurrent) return -1; + if (!aIsCurrent && bIsCurrent) return 1; + if (aIsCurrent && bIsCurrent) return 0; + + // If neither is current user, sort by selection state + const aSelected = a.value !== null && selectedSet.has(a.value); + const bSelected = b.value !== null && selectedSet.has(b.value); + + // If both selected or both unselected, maintain original order + if (aSelected === bSelected) return 0; + + // Selected items come before unselected + return aSelected ? -1 : 1; + }); +}; From 53b3358a633e733fdafe5bafc9b4ae96a5fcbf8a Mon Sep 17 00:00:00 2001 From: Jayash Tripathy <76092296+JayashTripathy@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:51:33 +0530 Subject: [PATCH 50/63] [GIT-44] refactor(auth): add PASSWORD_TOO_WEAK error code (#8522) * refactor(auth): add PASSWORD_TOO_WEAK error code and update related error handling in password change flow * fix(auth): update import to use type for EAuthenticationErrorCodes in security page * Update apps/web/app/(all)/profile/security/page.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: updated auth error exception accross zxcvbn usages * fix: improve error handling for password strength validation and update error messages * i18n(ru): update Russian translations for stickies and automation description Added translation for 'stickies' and improved formatting of the automation description in Russian locale. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/api/plane/authentication/adapter/base.py | 4 ++-- .../api/plane/authentication/adapter/error.py | 1 + .../views/app/password_management.py | 4 ++-- apps/api/plane/authentication/views/common.py | 4 ++-- .../views/space/password_management.py | 4 ++-- apps/api/plane/license/api/views/admin.py | 4 ++-- .../profile/content/pages/security.tsx | 22 +++++++++++++------ apps/web/helpers/authentication.helper.tsx | 12 ++++++++++ packages/constants/src/auth/index.ts | 1 + packages/i18n/src/locales/ru/translations.ts | 8 ++----- packages/utils/src/auth.ts | 4 ++++ 11 files changed, 45 insertions(+), 23 deletions(-) diff --git a/apps/api/plane/authentication/adapter/base.py b/apps/api/plane/authentication/adapter/base.py index 5707ca700a..7be6b8f683 100644 --- a/apps/api/plane/authentication/adapter/base.py +++ b/apps/api/plane/authentication/adapter/base.py @@ -85,8 +85,8 @@ class Adapter: results = zxcvbn(self.code) if results["score"] < 3: raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], - error_message="INVALID_PASSWORD", + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], + error_message="PASSWORD_TOO_WEAK", payload={"email": email}, ) return diff --git a/apps/api/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py index 74cb44d26a..f91565df2e 100644 --- a/apps/api/plane/authentication/adapter/error.py +++ b/apps/api/plane/authentication/adapter/error.py @@ -13,6 +13,7 @@ AUTHENTICATION_ERROR_CODES = { "USER_ACCOUNT_DEACTIVATED": 5019, # Password strength "INVALID_PASSWORD": 5020, + "PASSWORD_TOO_WEAK": 5021, "SMTP_NOT_CONFIGURED": 5025, # Sign Up "USER_ALREADY_EXIST": 5030, diff --git a/apps/api/plane/authentication/views/app/password_management.py b/apps/api/plane/authentication/views/app/password_management.py index 33a765134f..48b54dcccb 100644 --- a/apps/api/plane/authentication/views/app/password_management.py +++ b/apps/api/plane/authentication/views/app/password_management.py @@ -145,8 +145,8 @@ class ResetPasswordEndpoint(View): results = zxcvbn(password) if results["score"] < 3: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], - error_message="INVALID_PASSWORD", + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], + error_message="PASSWORD_TOO_WEAK", ) url = urljoin( base_host(request=request, is_app=True), diff --git a/apps/api/plane/authentication/views/common.py b/apps/api/plane/authentication/views/common.py index c36ae48321..086d6b0d3e 100644 --- a/apps/api/plane/authentication/views/common.py +++ b/apps/api/plane/authentication/views/common.py @@ -83,8 +83,8 @@ class ChangePasswordEndpoint(APIView): results = zxcvbn(new_password) if results["score"] < 3: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_NEW_PASSWORD"], - error_message="INVALID_NEW_PASSWORD", + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], + error_message="PASSWORD_TOO_WEAK", ) return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/api/plane/authentication/views/space/password_management.py b/apps/api/plane/authentication/views/space/password_management.py index 49fe4360c5..ed6682d74a 100644 --- a/apps/api/plane/authentication/views/space/password_management.py +++ b/apps/api/plane/authentication/views/space/password_management.py @@ -139,8 +139,8 @@ class ResetPasswordSpaceEndpoint(View): results = zxcvbn(password) if results["score"] < 3: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], - error_message="INVALID_PASSWORD", + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], + error_message="PASSWORD_TOO_WEAK", ) url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501 return HttpResponseRedirect(url) diff --git a/apps/api/plane/license/api/views/admin.py b/apps/api/plane/license/api/views/admin.py index ba75d52aa2..6217cc87fa 100644 --- a/apps/api/plane/license/api/views/admin.py +++ b/apps/api/plane/license/api/views/admin.py @@ -191,8 +191,8 @@ class InstanceAdminSignUpEndpoint(View): results = zxcvbn(password) if results["score"] < 3: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_PASSWORD"], - error_message="INVALID_ADMIN_PASSWORD", + error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"], + error_message="PASSWORD_TOO_WEAK", payload={ "email": email, "first_name": first_name, diff --git a/apps/web/core/components/settings/profile/content/pages/security.tsx b/apps/web/core/components/settings/profile/content/pages/security.tsx index 281da2dbab..2552c71e87 100644 --- a/apps/web/core/components/settings/profile/content/pages/security.tsx +++ b/apps/web/core/components/settings/profile/content/pages/security.tsx @@ -18,8 +18,7 @@ import { getPasswordStrength } from "@plane/utils"; // components import { ProfileSettingsHeading } from "@/components/settings/profile/heading"; // helpers -import { authErrorHandler } from "@/helpers/authentication.helper"; -import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; +import { authErrorHandler, EAuthenticationErrorCodes, passwordErrors } from "@/helpers/authentication.helper"; // hooks import { useUser } from "@/hooks/store/user"; // services @@ -58,6 +57,7 @@ export const SecurityProfileSettings = observer(function SecurityProfileSettings control, handleSubmit, watch, + setError, formState: { errors, isSubmitting }, reset, } = useForm({ defaultValues }); @@ -93,11 +93,9 @@ export const SecurityProfileSettings = observer(function SecurityProfileSettings message: t("auth.common.password.toast.change_password.success.message"), }); } catch (error: unknown) { - let errorInfo = undefined; - if (error instanceof Error) { - const code = "error_code" in error ? error.error_code?.toString() : undefined; - errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined; - } + const err = error as Error & { error_code?: string }; + const code = err.error_code?.toString(); + const errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined; setToast({ type: TOAST_TYPE.ERROR, @@ -105,6 +103,13 @@ export const SecurityProfileSettings = observer(function SecurityProfileSettings message: typeof errorInfo?.message === "string" ? errorInfo.message : t("auth.common.password.toast.error.message"), }); + + if (code && passwordErrors.includes(code as EAuthenticationErrorCodes)) { + setError("new_password", { + type: "manual", + message: errorInfo?.message?.toString() || t("auth.common.password.toast.error.message"), + }); + } } }; @@ -204,6 +209,9 @@ export const SecurityProfileSettings = observer(function SecurityProfileSettings )}
{passwordSupport} + {errors.new_password && ( + {errors.new_password.message} + )} {isNewPasswordSameAsOldPassword && !isPasswordInputFocused && ( {t("new_password_must_be_different_from_old_password")} diff --git a/apps/web/helpers/authentication.helper.tsx b/apps/web/helpers/authentication.helper.tsx index ccbb568009..2df4277389 100644 --- a/apps/web/helpers/authentication.helper.tsx +++ b/apps/web/helpers/authentication.helper.tsx @@ -47,6 +47,7 @@ export enum EAuthenticationErrorCodes { USER_ACCOUNT_DEACTIVATED = "5019", // Password strength INVALID_PASSWORD = "5020", + PASSWORD_TOO_WEAK = "5021", SMTP_NOT_CONFIGURED = "5025", // Sign Up USER_ALREADY_EXIST = "5030", @@ -107,6 +108,7 @@ export type TAuthErrorInfo = { message: ReactNode; }; +// TODO: move all error messages to translation files const errorCodeMessages: { [key in EAuthenticationErrorCodes]: { title: string; message: (email?: string) => ReactNode }; } = { @@ -143,6 +145,10 @@ const errorCodeMessages: { title: `Invalid password`, message: () => `Invalid password. Please try again.`, }, + [EAuthenticationErrorCodes.PASSWORD_TOO_WEAK]: { + title: `Password too weak`, + message: () => `Please use a stronger password.`, + }, [EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED]: { title: `SMTP not configured`, message: () => `SMTP not configured. Please contact your administrator.`, @@ -418,6 +424,7 @@ export const authErrorHandler = (errorCode: EAuthenticationErrorCodes, email?: s EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST, EAuthenticationErrorCodes.ADMIN_USER_DEACTIVATED, EAuthenticationErrorCodes.RATE_LIMIT_EXCEEDED, + EAuthenticationErrorCodes.PASSWORD_TOO_WEAK, ]; if (bannerAlertErrorCodes.includes(errorCode)) @@ -430,3 +437,8 @@ export const authErrorHandler = (errorCode: EAuthenticationErrorCodes, email?: s return undefined; }; + +export const passwordErrors = [ + EAuthenticationErrorCodes.PASSWORD_TOO_WEAK, + EAuthenticationErrorCodes.INVALID_NEW_PASSWORD, +]; diff --git a/packages/constants/src/auth/index.ts b/packages/constants/src/auth/index.ts index 6da55498a7..32a7d5eee6 100644 --- a/packages/constants/src/auth/index.ts +++ b/packages/constants/src/auth/index.ts @@ -114,6 +114,7 @@ export enum EAuthErrorCodes { USER_ACCOUNT_DEACTIVATED = "5019", // Password strength INVALID_PASSWORD = "5020", + PASSWORD_TOO_WEAK = "5021", SMTP_NOT_CONFIGURED = "5025", // Sign Up USER_ALREADY_EXIST = "5030", diff --git a/packages/i18n/src/locales/ru/translations.ts b/packages/i18n/src/locales/ru/translations.ts index 3952883d6c..6335c35b76 100644 --- a/packages/i18n/src/locales/ru/translations.ts +++ b/packages/i18n/src/locales/ru/translations.ts @@ -23,6 +23,7 @@ export default { favorites: "Избранное", pro: "Pro", upgrade: "Обновить", + stickies: "Стикеры", }, auth: { common: { @@ -2002,8 +2003,7 @@ export default { automations: { label: "Автоматизация", heading: "Автоматизация", - description: - "Настройте автоматические действия для оптимизации рабочего процесса и сокращения ручных задач.", + description: "Настройте автоматические действия для оптимизации рабочего процесса и сокращения ручных задач.", "auto-archive": { title: "Автоархивация закрытых рабочих элементов", description: "Plane будет автоматически архивировать рабочие элементы, которые были завершены или отменены.", @@ -2921,8 +2921,4 @@ export default { enter_number_of_projects: "Введите количество проектов", pin: "Закрепить", unpin: "Открепить", - sidebar: { - stickies: "Стикеры", - your_work: "Ваша работа", - }, } as const; diff --git a/packages/utils/src/auth.ts b/packages/utils/src/auth.ts index d853bccbec..fd8b80ad00 100644 --- a/packages/utils/src/auth.ts +++ b/packages/utils/src/auth.ts @@ -99,6 +99,10 @@ const errorCodeMessages: { title: `Invalid password`, message: () => `Invalid password. Please try again.`, }, + [EAuthErrorCodes.PASSWORD_TOO_WEAK]: { + title: `Password too weak`, + message: () => `Please use a stronger password.`, + }, [EAuthErrorCodes.SMTP_NOT_CONFIGURED]: { title: `SMTP not configured`, message: () => `SMTP not configured. Please contact your administrator.`, From 7e5b5066c5ea06f92c8eb0466b0fa996979dbc1c Mon Sep 17 00:00:00 2001 From: Cornelius <70640137+conny3496@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:34:02 +0000 Subject: [PATCH 51/63] Update translations.ts: issue-artifacts discoverd (#7979) --- packages/i18n/src/locales/en/translations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/i18n/src/locales/en/translations.ts b/packages/i18n/src/locales/en/translations.ts index 434d950a23..5df71b2eaa 100644 --- a/packages/i18n/src/locales/en/translations.ts +++ b/packages/i18n/src/locales/en/translations.ts @@ -1242,7 +1242,7 @@ export default { comic: { title: "Analytics works best with Cycles + Modules", description: - "First, timebox your issues into Cycles and, if you can, group issues that span more than a cycle into Modules. Check out both on the left nav.", + "First, timebox your work items into Cycles and, if you can, group work items that span more than a cycle into Modules. Check out both on the left nav.", }, }, }, From e92b835869678be5b99c6445ee87a41b3df19914 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:04:57 +0530 Subject: [PATCH 52/63] [WEB-5873] fix: user avatar ui consistency (#8495) * fix: user avatar ui consistency * chore: code refactor --- .../core/components/project/settings/member-columns.tsx | 4 ++-- .../core/components/workspace/settings/member-columns.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/core/components/project/settings/member-columns.tsx b/apps/web/core/components/project/settings/member-columns.tsx index 813fa180cd..b3554efc0b 100644 --- a/apps/web/core/components/project/settings/member-columns.tsx +++ b/apps/web/core/components/project/settings/member-columns.tsx @@ -51,7 +51,7 @@ export function NameColumn(props: NameProps) {
{avatar_url && avatar_url.trim() !== "" ? ( - + ) : ( - + {(email ?? display_name ?? "?")[0]} diff --git a/apps/web/core/components/workspace/settings/member-columns.tsx b/apps/web/core/components/workspace/settings/member-columns.tsx index bf27b05220..5af508b9ef 100644 --- a/apps/web/core/components/workspace/settings/member-columns.tsx +++ b/apps/web/core/components/workspace/settings/member-columns.tsx @@ -55,12 +55,12 @@ export function NameColumn(props: NameProps) {
{isSuspended ? ( -
- +
+
) : avatar_url && avatar_url.trim() !== "" ? ( - + ) : ( - + {(email ?? display_name ?? "?")[0]} From c8a800104c02aecd0bea3ad7a54878bab2eb490e Mon Sep 17 00:00:00 2001 From: Dheeraj Kumar Ketireddy Date: Tue, 17 Feb 2026 00:01:33 +0530 Subject: [PATCH 53/63] [SILO-820] fix: update serializer for module detail API endpoint to use ModuleUpdateSerializer (#8496) --- apps/api/plane/api/views/module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/plane/api/views/module.py b/apps/api/plane/api/views/module.py index fa2269ca09..61e198b481 100644 --- a/apps/api/plane/api/views/module.py +++ b/apps/api/plane/api/views/module.py @@ -414,7 +414,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView): {"error": "Archived module cannot be edited"}, status=status.HTTP_400_BAD_REQUEST, ) - serializer = ModuleSerializer(module, data=request.data, context={"project_id": project_id}, partial=True) + serializer = ModuleUpdateSerializer(module, data=request.data, context={"project_id": project_id}, partial=True) if serializer.is_valid(): if ( request.data.get("external_id") From ef5d481a190f0f08ee36aa4677e7a660dcef8e4f Mon Sep 17 00:00:00 2001 From: Dheeraj Kumar Ketireddy Date: Tue, 17 Feb 2026 00:02:18 +0530 Subject: [PATCH 54/63] [VPAT-51] fix: update workspace invitation flow to use token for validation #8508 - Modified the invite link to include a token for enhanced security. - Updated the WorkspaceJoinEndpoint to validate the token instead of the email. - Adjusted the workspace invitation task to generate links with the token. - Refactored the frontend to handle token in the invitation process. Co-authored-by: sriram veeraghanta --- apps/api/plane/app/serializers/workspace.py | 2 +- apps/api/plane/app/views/workspace/invite.py | 8 ++++---- .../bgtasks/workspace_invitation_task.py | 2 +- .../app/(all)/workspace-invitations/page.tsx | 19 +++++++++---------- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/api/plane/app/serializers/workspace.py b/apps/api/plane/app/serializers/workspace.py index d6707815e5..608cdad851 100644 --- a/apps/api/plane/app/serializers/workspace.py +++ b/apps/api/plane/app/serializers/workspace.py @@ -111,7 +111,7 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer): invite_link = serializers.SerializerMethodField() def get_invite_link(self, obj): - return f"/workspace-invitations/?invitation_id={obj.id}&email={obj.email}&slug={obj.workspace.slug}" + return f"/workspace-invitations/?invitation_id={obj.id}&slug={obj.workspace.slug}&token={obj.token}" class Meta: model = WorkspaceMemberInvite diff --git a/apps/api/plane/app/views/workspace/invite.py b/apps/api/plane/app/views/workspace/invite.py index 175380b3aa..cf2ab795a7 100644 --- a/apps/api/plane/app/views/workspace/invite.py +++ b/apps/api/plane/app/views/workspace/invite.py @@ -163,10 +163,10 @@ class WorkspaceJoinEndpoint(BaseAPIView): def post(self, request, slug, pk): workspace_invite = WorkspaceMemberInvite.objects.get(pk=pk, workspace__slug=slug) - email = request.data.get("email", "") + token = request.data.get("token", "") - # Check the email - if email == "" or workspace_invite.email != email: + # Validate the token to verify the user received the invitation email + if not token or workspace_invite.token != token: return Response( {"error": "You do not have permission to join the workspace"}, status=status.HTTP_403_FORBIDDEN, @@ -180,7 +180,7 @@ class WorkspaceJoinEndpoint(BaseAPIView): if workspace_invite.accepted: # Check if the user created account after invitation - user = User.objects.filter(email=email).first() + user = User.objects.filter(email=workspace_invite.email).first() # If the user is present then create the workspace member if user is not None: diff --git a/apps/api/plane/bgtasks/workspace_invitation_task.py b/apps/api/plane/bgtasks/workspace_invitation_task.py index ced17d599d..9e9a17c2f3 100644 --- a/apps/api/plane/bgtasks/workspace_invitation_task.py +++ b/apps/api/plane/bgtasks/workspace_invitation_task.py @@ -29,7 +29,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter): # Relative link relative_link = ( - f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501 + f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&slug={workspace.slug}&token={token}" # noqa: E501 ) # The complete url including the domain diff --git a/apps/web/app/(all)/workspace-invitations/page.tsx b/apps/web/app/(all)/workspace-invitations/page.tsx index eb9c92128d..6d68136097 100644 --- a/apps/web/app/(all)/workspace-invitations/page.tsx +++ b/apps/web/app/(all)/workspace-invitations/page.tsx @@ -4,7 +4,6 @@ * See the LICENSE file for details. */ -import React from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; import useSWR from "swr"; @@ -34,8 +33,8 @@ function WorkspaceInvitationPage() { // query params const searchParams = useSearchParams(); const invitation_id = searchParams.get("invitation_id"); - const email = searchParams.get("email"); const slug = searchParams.get("slug"); + const token = searchParams.get("token"); // store hooks const { data: currentUser } = useUser(); @@ -51,29 +50,29 @@ function WorkspaceInvitationPage() { workspaceService .joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { accepted: true, - email: invitationDetail.email, + token: token, }) .then(() => { - if (email === currentUser?.email) { + if (invitationDetail.email === currentUser?.email) { router.push(`/${invitationDetail.workspace.slug}`); } else { - router.push(`/?${searchParams.toString()}`); + router.push("/"); } }) - .catch((err) => console.error(err)); + .catch((err: unknown) => console.error(err)); }; const handleReject = () => { - if (!invitationDetail) return; - workspaceService + if (!invitationDetail || !token) return; + void workspaceService .joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { accepted: false, - email: invitationDetail.email, + token: token, }) .then(() => { router.push("/"); }) - .catch((err) => console.error(err)); + .catch((err: unknown) => console.error(err)); }; return ( From 3a99ecf8f3d8e159b5bb65557e5b2c7fd9b81a51 Mon Sep 17 00:00:00 2001 From: Sangeetha Date: Tue, 17 Feb 2026 00:04:03 +0530 Subject: [PATCH 55/63] [WEB-5871] chore: added intake count for projects (#8497) * chore: add intake_count in project list endpoint * chore: sidebar project navigation intake count added * fix: filter out closed intake issues in the count * chore: code refactor * chore: code refactor * fix: filter out deleted intake issues --------- Co-authored-by: Anmol Singh Bhatia --- apps/api/plane/app/views/project/base.py | 15 +++++-- .../workspace/sidebar/project-navigation.tsx | 13 ++++-- .../web/core/store/inbox/inbox-issue.store.ts | 43 ++++++++++++++++++- .../core/store/inbox/project-inbox.store.ts | 11 +++++ packages/types/src/project/projects.ts | 1 + 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py index 6b7a6f06fe..8164b4df1e 100644 --- a/apps/api/plane/app/views/project/base.py +++ b/apps/api/plane/app/views/project/base.py @@ -8,7 +8,7 @@ import json # Django imports from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery +from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery, Count from django.utils import timezone # Third Party imports @@ -28,7 +28,6 @@ from plane.bgtasks.webhook_task import model_activity, webhook_activity from plane.db.models import ( UserFavorite, DeployBoard, - ProjectUserProperty, Intake, Project, ProjectIdentifier, @@ -36,10 +35,10 @@ from plane.db.models import ( ProjectNetwork, State, DEFAULT_STATES, - UserFavorite, Workspace, WorkspaceMember, ) +from plane.db.models.intake import IntakeIssueStatus from plane.utils.host import base_host @@ -155,6 +154,15 @@ class ProjectViewSet(BaseViewSet): is_active=True, ).values("role") ) + .annotate( + intake_count=Count( + "project_intakeissue", + filter=Q( + project_intakeissue__status=IntakeIssueStatus.PENDING.value, + project_intakeissue__deleted_at__isnull=True, + ), + ) + ) .annotate(inbox_view=F("intake_view")) .annotate(sort_order=Subquery(sort_order)) .distinct() @@ -165,6 +173,7 @@ class ProjectViewSet(BaseViewSet): "sort_order", "logo_props", "member_role", + "intake_count", "archived_at", "workspace", "cycle_view", diff --git a/apps/web/core/components/workspace/sidebar/project-navigation.tsx b/apps/web/core/components/workspace/sidebar/project-navigation.tsx index 95914de569..e63e768e4b 100644 --- a/apps/web/core/components/workspace/sidebar/project-navigation.tsx +++ b/apps/web/core/components/workspace/sidebar/project-navigation.tsx @@ -181,12 +181,19 @@ export const ProjectNavigation = observer(function ProjectNavigation(props: TPro const hasAccess = allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, project.id); if (!hasAccess) return null; + const shouldShowCount = item.key === "intake" && (project.intake_count ?? 0) > 0; + return ( -
- - {t(item.i18n_key)} +
+
+ + {t(item.i18n_key)} +
+ {shouldShowCount && {project.intake_count}}
diff --git a/apps/web/core/store/inbox/inbox-issue.store.ts b/apps/web/core/store/inbox/inbox-issue.store.ts index 4181ba7751..233d0a9bcf 100644 --- a/apps/web/core/store/inbox/inbox-issue.store.ts +++ b/apps/web/core/store/inbox/inbox-issue.store.ts @@ -100,6 +100,7 @@ export class InboxIssueStore implements IInboxIssueStore { const previousData: Partial = { status: this.status, }; + const previousStatus = this.status; try { if (!this.issue.id) return; @@ -107,7 +108,24 @@ export class InboxIssueStore implements IInboxIssueStore { const inboxIssue = await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, { status: status, }); - runInAction(() => set(this, "status", inboxIssue?.status)); + runInAction(() => { + set(this, "status", inboxIssue?.status); + + // Handle intake_count transitions + if (previousStatus === EInboxIssueStatus.PENDING && inboxIssue.status !== EInboxIssueStatus.PENDING) { + // Changed from PENDING to something else: decrement + const currentCount = this.store.projectRoot.project.projectMap[this.projectId]?.intake_count ?? 0; + set( + this.store.projectRoot.project.projectMap, + [this.projectId, "intake_count"], + Math.max(0, currentCount - 1) + ); + } else if (previousStatus !== EInboxIssueStatus.PENDING && inboxIssue.status === EInboxIssueStatus.PENDING) { + // Changed from something else to PENDING: increment + const currentCount = this.store.projectRoot.project.projectMap[this.projectId]?.intake_count ?? 0; + set(this.store.projectRoot.project.projectMap, [this.projectId, "intake_count"], currentCount + 1); + } + }); // If issue accepted sync issue to local db if (status === EInboxIssueStatus.ACCEPTED) { @@ -126,6 +144,7 @@ export class InboxIssueStore implements IInboxIssueStore { duplicate_to: this.duplicate_to, duplicate_issue_detail: this.duplicate_issue_detail, }; + const wasPending = this.status === EInboxIssueStatus.PENDING; try { if (!this.issue.id) return; const inboxIssue = await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, { @@ -136,6 +155,15 @@ export class InboxIssueStore implements IInboxIssueStore { set(this, "status", inboxIssue?.status); set(this, "duplicate_to", inboxIssue?.duplicate_to); set(this, "duplicate_issue_detail", inboxIssue?.duplicate_issue_detail); + // Decrement intake_count if the issue was PENDING + if (wasPending) { + const currentCount = this.store.projectRoot.project.projectMap[this.projectId]?.intake_count ?? 0; + set( + this.store.projectRoot.project.projectMap, + [this.projectId, "intake_count"], + Math.max(0, currentCount - 1) + ); + } }); } catch { runInAction(() => { @@ -152,6 +180,7 @@ export class InboxIssueStore implements IInboxIssueStore { status: this.status, snoozed_till: this.snoozed_till, }; + const previousStatus = this.status; try { if (!this.issue.id) return; const inboxIssue = await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, { @@ -161,6 +190,18 @@ export class InboxIssueStore implements IInboxIssueStore { runInAction(() => { set(this, "status", inboxIssue?.status); set(this, "snoozed_till", inboxIssue?.snoozed_till); + // Handle intake_count transitions + if (previousStatus === EInboxIssueStatus.PENDING && inboxIssue.status === EInboxIssueStatus.SNOOZED) { + const currentCount = this.store.projectRoot.project.projectMap[this.projectId]?.intake_count ?? 0; + set( + this.store.projectRoot.project.projectMap, + [this.projectId, "intake_count"], + Math.max(0, currentCount - 1) + ); + } else if (previousStatus !== EInboxIssueStatus.PENDING && inboxIssue.status === EInboxIssueStatus.PENDING) { + const currentCount = this.store.projectRoot.project.projectMap[this.projectId]?.intake_count ?? 0; + set(this.store.projectRoot.project.projectMap, [this.projectId, "intake_count"], currentCount + 1); + } }); } catch { runInAction(() => { diff --git a/apps/web/core/store/inbox/project-inbox.store.ts b/apps/web/core/store/inbox/project-inbox.store.ts index df776b88ee..6dc1f96b56 100644 --- a/apps/web/core/store/inbox/project-inbox.store.ts +++ b/apps/web/core/store/inbox/project-inbox.store.ts @@ -473,6 +473,11 @@ export class ProjectInboxStore implements IProjectInboxStore { ["inboxIssuePaginationInfo", "total_results"], (this.inboxIssuePaginationInfo?.total_results || 0) + 1 ); + // Increment intake_count if the new issue is PENDING + if (inboxIssueResponse.status === EInboxIssueStatus.PENDING) { + const currentCount = this.store.projectRoot.project.projectMap[projectId]?.intake_count ?? 0; + set(this.store.projectRoot.project.projectMap, [projectId, "intake_count"], currentCount + 1); + } }); return inboxIssueResponse; } catch (error) { @@ -489,6 +494,7 @@ export class ProjectInboxStore implements IProjectInboxStore { */ deleteInboxIssue = async (workspaceSlug: string, projectId: string, inboxIssueId: string) => { const currentIssue = this.inboxIssues?.[inboxIssueId]; + const wasPending = currentIssue?.status === EInboxIssueStatus.PENDING; try { if (!currentIssue) return; await this.inboxIssueService.destroy(workspaceSlug, projectId, inboxIssueId).then(() => { @@ -504,6 +510,11 @@ export class ProjectInboxStore implements IProjectInboxStore { ["inboxIssueIds"], this.inboxIssueIds.filter((id) => id !== inboxIssueId) ); + // Decrement intake_count if the deleted issue was PENDING + if (wasPending) { + const currentCount = this.store.projectRoot.project.projectMap[projectId]?.intake_count ?? 0; + set(this.store.projectRoot.project.projectMap, [projectId, "intake_count"], Math.max(0, currentCount - 1)); + } }); }); } catch (error) { diff --git a/packages/types/src/project/projects.ts b/packages/types/src/project/projects.ts index 358dbba339..3cdbed1deb 100644 --- a/packages/types/src/project/projects.ts +++ b/packages/types/src/project/projects.ts @@ -39,6 +39,7 @@ export interface IPartialProject { // actor created_by?: string; updated_by?: string; + intake_count?: number; } export interface IProject extends IPartialProject { From 4d1e6c499f1367c05d893805125be782896ad921 Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com> Date: Tue, 17 Feb 2026 00:05:20 +0530 Subject: [PATCH 56/63] [WEB-5829] fix: Intake open work count (#8547) * fix: open intake count at sidebar header * chore: reverted inbox store arguments to core store * fix: intake count update --- apps/web/core/store/inbox/inbox-issue.store.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/core/store/inbox/inbox-issue.store.ts b/apps/web/core/store/inbox/inbox-issue.store.ts index 233d0a9bcf..49a7d78840 100644 --- a/apps/web/core/store/inbox/inbox-issue.store.ts +++ b/apps/web/core/store/inbox/inbox-issue.store.ts @@ -127,6 +127,11 @@ export class InboxIssueStore implements IInboxIssueStore { } }); + // Update counts + const currentTotalResults = this.store.projectInbox.inboxIssuePaginationInfo?.total_results ?? 0; + const updatedCount = currentTotalResults > 0 ? currentTotalResults - 1 : currentTotalResults; + set(this.store.projectInbox, ["inboxIssuePaginationInfo", "total_results"], updatedCount); + // If issue accepted sync issue to local db if (status === EInboxIssueStatus.ACCEPTED) { const updatedIssue = { ...this.issue, ...inboxIssue.issue }; From 55e89cb8fcd8e29f863e288eafe54dbfa3682169 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 17 Feb 2026 00:12:33 +0530 Subject: [PATCH 57/63] [WEB-5863] fix: estimate point input validation #8492 Co-authored-by: sriram veeraghanta --- apps/web/core/components/estimates/inputs/number-input.tsx | 2 +- apps/web/core/components/estimates/inputs/root.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/core/components/estimates/inputs/number-input.tsx b/apps/web/core/components/estimates/inputs/number-input.tsx index 24e9d7a2bb..6f898dfa7d 100644 --- a/apps/web/core/components/estimates/inputs/number-input.tsx +++ b/apps/web/core/components/estimates/inputs/number-input.tsx @@ -24,7 +24,7 @@ export function EstimateNumberInput(props: TEstimateNumberInputProps) { className="border-none focus:ring-0 focus:border-0 focus:outline-none px-2 py-2 w-full bg-transparent text-13" placeholder={t("project_settings.estimates.create.enter_estimate_point")} autoFocus - type="number" + step="any" /> ); } diff --git a/apps/web/core/components/estimates/inputs/root.tsx b/apps/web/core/components/estimates/inputs/root.tsx index ac7a03cc41..1c38f41cfe 100644 --- a/apps/web/core/components/estimates/inputs/root.tsx +++ b/apps/web/core/components/estimates/inputs/root.tsx @@ -27,7 +27,7 @@ export function EstimateInputRoot(props: TEstimateInputRootProps) { case EEstimateSystem.POINTS: return ( ); From 49fc6aa0a044977cbaa73b8b40330ccd9fd14929 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Tue, 17 Feb 2026 00:18:46 +0530 Subject: [PATCH 58/63] [VPAT-55] chore(security): implement input validation across authentication and workspace forms (#8528) * chore(security): implement input validation across authentication and workspace forms - Add OWASP-compliant autocomplete attributes to all auth input fields - Create centralized validation utilities blocking injection-risk characters - Apply validation to names, display names, workspace names, and slugs - Block special characters: < > ' " % # { } [ ] * ^ ! - Secure sensitive input fields across admin, web, and space apps * chore: add missing workspace name validation to settings and admin forms * feat: enhance validation regex for international names and usernames - Updated regex patterns to support Unicode characters for person names, display names, company names, and slugs. - Improved validation functions to block injection-risk characters in names and slugs. --- .../(dashboard)/workspace/create/form.tsx | 16 +- apps/admin/components/instance/setup-form.tsx | 30 ++- .../onboarding/create-workspace.tsx | 7 +- .../components/onboarding/profile-setup.tsx | 12 +- .../onboarding/steps/profile/root.tsx | 7 +- .../onboarding/steps/workspace/create.tsx | 8 +- .../profile/content/pages/general/form.tsx | 22 +- .../workspace/create-workspace-form.tsx | 7 +- .../workspace/settings/workspace-details.tsx | 9 +- packages/utils/src/index.ts | 1 + packages/utils/src/validation.ts | 216 ++++++++++++++++++ 11 files changed, 281 insertions(+), 54 deletions(-) create mode 100644 packages/utils/src/validation.ts diff --git a/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx b/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx index c9e0704306..46238940d6 100644 --- a/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx @@ -14,6 +14,7 @@ import { Button, getButtonStyling } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { InstanceWorkspaceService } from "@plane/services"; import type { IWorkspace } from "@plane/types"; +import { validateSlug, validateWorkspaceName } from "@plane/utils"; // components import { CustomSelect, Input } from "@plane/ui"; // hooks @@ -96,14 +97,7 @@ export function WorkspaceCreateForm() { control={control} name="name" rules={{ - required: "This is a required field.", - validate: (value) => - /^[\w\s-]*$/.test(value) || - `Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`, - maxLength: { - value: 80, - message: "Limit your name to 80 characters.", - }, + validate: (value) => validateWorkspaceName(value, true), }} render={({ field: { value, ref, onChange } }) => ( validateSlug(value), }} render={({ field: { onChange, value, ref } }) => ( handleFormChange("first_name", e.target.value)} - autoComplete="on" + onChange={(e) => { + const validation = validatePersonName(e.target.value); + if (validation === true || e.target.value === "") { + handleFormChange("first_name", e.target.value); + } + }} + autoComplete="off" autoFocus + maxLength={50} />
@@ -190,8 +196,14 @@ export function InstanceSetupForm() { inputSize="md" placeholder="Wright" value={formData.last_name} - onChange={(e) => handleFormChange("last_name", e.target.value)} - autoComplete="on" + onChange={(e) => { + const validation = validatePersonName(e.target.value); + if (validation === true || e.target.value === "") { + handleFormChange("last_name", e.target.value); + } + }} + autoComplete="off" + maxLength={50} />
@@ -229,7 +241,13 @@ export function InstanceSetupForm() { inputSize="md" placeholder="Company name" value={formData.company_name} - onChange={(e) => handleFormChange("company_name", e.target.value)} + onChange={(e) => { + const validation = validateCompanyName(e.target.value, false); + if (validation === true || e.target.value === "") { + handleFormChange("company_name", e.target.value); + } + }} + maxLength={80} />
diff --git a/apps/web/core/components/onboarding/create-workspace.tsx b/apps/web/core/components/onboarding/create-workspace.tsx index 1cf5d087cc..85a45d2e39 100644 --- a/apps/web/core/components/onboarding/create-workspace.tsx +++ b/apps/web/core/components/onboarding/create-workspace.tsx @@ -16,6 +16,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IUser, IWorkspace, TOnboardingSteps } from "@plane/types"; // ui import { CustomSelect, Input, Spinner } from "@plane/ui"; +import { validateWorkspaceName, validateSlug } from "@plane/utils"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUserProfile, useUserSettings } from "@/hooks/store/user"; @@ -138,8 +139,7 @@ export const CreateWorkspace = observer(function CreateWorkspace(props: Props) { name="name" rules={{ required: t("common.errors.required"), - validate: (value) => - /^[\w\s-]*$/.test(value) || t("workspace_creation.errors.validation.name_alphanumeric"), + validate: (value) => validateWorkspaceName(value, true), maxLength: { value: 80, message: t("workspace_creation.errors.validation.name_length"), @@ -200,7 +200,8 @@ export const CreateWorkspace = observer(function CreateWorkspace(props: Props) { type="text" value={value.toLocaleLowerCase().trim().replace(/ /g, "-")} onChange={(e) => { - if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false); + const validation = validateSlug(e.target.value); + if (validation === true) setInvalidSlug(false); else setInvalidSlug(true); onChange(e.target.value.toLowerCase()); }} diff --git a/apps/web/core/components/onboarding/profile-setup.tsx b/apps/web/core/components/onboarding/profile-setup.tsx index 41e384b21f..0f8be9482a 100644 --- a/apps/web/core/components/onboarding/profile-setup.tsx +++ b/apps/web/core/components/onboarding/profile-setup.tsx @@ -17,7 +17,7 @@ import type { IUser, TUserProfile, TOnboardingSteps } from "@plane/types"; // ui import { Input, PasswordStrengthIndicator, Spinner } from "@plane/ui"; // components -import { cn, getFileURL, getPasswordStrength } from "@plane/utils"; +import { cn, getFileURL, getPasswordStrength, validatePersonName } from "@plane/utils"; import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal"; // hooks import { useUser, useUserProfile } from "@/hooks/store/user"; @@ -303,9 +303,10 @@ export const ProfileSetup = observer(function ProfileSetup(props: Props) { name="first_name" rules={{ required: "First name is required", + validate: validatePersonName, maxLength: { - value: 24, - message: "First name must be within 24 characters.", + value: 50, + message: "First name must be within 50 characters.", }, }} render={({ field: { value, onChange, ref } }) => ( @@ -340,9 +341,10 @@ export const ProfileSetup = observer(function ProfileSetup(props: Props) { name="last_name" rules={{ required: "Last name is required", + validate: validatePersonName, maxLength: { - value: 24, - message: "Last name must be within 24 characters.", + value: 50, + message: "Last name must be within 50 characters.", }, }} render={({ field: { value, onChange, ref } }) => ( diff --git a/apps/web/core/components/onboarding/steps/profile/root.tsx b/apps/web/core/components/onboarding/steps/profile/root.tsx index 788e389669..ca8a648b92 100644 --- a/apps/web/core/components/onboarding/steps/profile/root.tsx +++ b/apps/web/core/components/onboarding/steps/profile/root.tsx @@ -14,7 +14,7 @@ import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IUser } from "@plane/types"; import { EOnboardingSteps } from "@plane/types"; -import { cn, getFileURL, getPasswordStrength } from "@plane/utils"; +import { cn, getFileURL, getPasswordStrength, validatePersonName } from "@plane/utils"; // components import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal"; // hooks @@ -208,9 +208,10 @@ export const ProfileSetupStep = observer(function ProfileSetupStep({ handleStepC name="first_name" rules={{ required: "Name is required", + validate: validatePersonName, maxLength: { - value: 24, - message: "Name must be within 24 characters.", + value: 50, + message: "Name must be within 50 characters.", }, }} render={({ field: { value, onChange, ref } }) => ( diff --git a/apps/web/core/components/onboarding/steps/workspace/create.tsx b/apps/web/core/components/onboarding/steps/workspace/create.tsx index 6b5eccf264..371c4532ab 100644 --- a/apps/web/core/components/onboarding/steps/workspace/create.tsx +++ b/apps/web/core/components/onboarding/steps/workspace/create.tsx @@ -15,7 +15,7 @@ import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IUser, IWorkspace } from "@plane/types"; import { Spinner } from "@plane/ui"; -import { cn } from "@plane/utils"; +import { cn, validateWorkspaceName, validateSlug } from "@plane/utils"; // hooks import { useInstance } from "@/hooks/store/use-instance"; import { useWorkspace } from "@/hooks/store/use-workspace"; @@ -146,8 +146,7 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({ name="name" rules={{ required: t("common.errors.required"), - validate: (value) => - /^[\w\s-]*$/.test(value) || t("workspace_creation.errors.validation.name_alphanumeric"), + validate: (value) => validateWorkspaceName(value, true), maxLength: { value: 80, message: t("workspace_creation.errors.validation.name_length"), @@ -220,7 +219,8 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({ type="text" value={value.toLocaleLowerCase().trim().replace(/ /g, "-")} onChange={(e) => { - if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false); + const validation = validateSlug(e.target.value); + if (validation === true) setInvalidSlug(false); else setInvalidSlug(true); onChange(e.target.value.toLowerCase()); }} diff --git a/apps/web/core/components/settings/profile/content/pages/general/form.tsx b/apps/web/core/components/settings/profile/content/pages/general/form.tsx index b92388e1d3..f4f67e5b3a 100644 --- a/apps/web/core/components/settings/profile/content/pages/general/form.tsx +++ b/apps/web/core/components/settings/profile/content/pages/general/form.tsx @@ -28,6 +28,8 @@ import { handleCoverImageChange } from "@/helpers/cover-image.helper"; // hooks import { useInstance } from "@/hooks/store/use-instance"; import { useUser, useUserProfile } from "@/hooks/store/user"; +// utils +import { validatePersonName, validateDisplayName } from "@plane/utils"; type TUserProfileForm = { avatar_url: string; @@ -260,6 +262,7 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin name="first_name" rules={{ required: "Please enter first name", + validate: validatePersonName, }} render={({ field: { value, onChange, ref } }) => ( )} @@ -284,6 +287,9 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin ( )} /> + {errors.last_name && {errors.last_name.message}}

@@ -311,14 +318,7 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin name="display_name" rules={{ required: "Display name is required.", - validate: (value) => { - if (value.trim().length < 1) return "Display name can't be empty."; - if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; - if (value.replace(/\s/g, "").length < 1) return "Display name must be at least 1 character long."; - if (value.replace(/\s/g, "").length > 20) - return "Display name must be less than 20 characters long."; - return true; - }, + validate: validateDisplayName, }} render={({ field: { value, onChange, ref } }) => ( )} /> diff --git a/apps/web/core/components/workspace/create-workspace-form.tsx b/apps/web/core/components/workspace/create-workspace-form.tsx index c1d7e983aa..c8ea50dbdb 100644 --- a/apps/web/core/components/workspace/create-workspace-form.tsx +++ b/apps/web/core/components/workspace/create-workspace-form.tsx @@ -15,6 +15,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IWorkspace } from "@plane/types"; // ui import { CustomSelect, Input } from "@plane/ui"; +import { validateWorkspaceName, validateSlug } from "@plane/utils"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; import { useAppRouter } from "@/hooks/use-app-router"; @@ -126,8 +127,7 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props: name="name" rules={{ required: t("common.errors.required"), - validate: (value) => - /^[\w\s-]*$/.test(value) || t("workspace_creation.errors.validation.name_alphanumeric"), + validate: (value) => validateWorkspaceName(value, true), maxLength: { value: 80, message: t("workspace_creation.errors.validation.name_length"), @@ -178,7 +178,8 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props: type="text" value={value.toLocaleLowerCase().trim().replace(/ /g, "-")} onChange={(e) => { - if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false); + const validation = validateSlug(e.target.value); + if (validation === true) setInvalidSlug(false); else setInvalidSlug(true); onChange(e.target.value.toLowerCase()); }} diff --git a/apps/web/core/components/workspace/settings/workspace-details.tsx b/apps/web/core/components/workspace/settings/workspace-details.tsx index eec1dda17f..42522cd19e 100644 --- a/apps/web/core/components/workspace/settings/workspace-details.tsx +++ b/apps/web/core/components/workspace/settings/workspace-details.tsx @@ -15,7 +15,7 @@ import { EditIcon } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IWorkspace } from "@plane/types"; import { CustomSelect, Input } from "@plane/ui"; -import { cn, copyUrlToClipboard, getFileURL } from "@plane/utils"; +import { cn, copyUrlToClipboard, getFileURL, validateWorkspaceName } from "@plane/utils"; // components import { WorkspaceImageUploadModal } from "@/components/core/modals/workspace-image-upload-modal"; import { TimezoneSelect } from "@/components/global/timezone-select"; @@ -195,11 +195,7 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() { control={control} name="name" rules={{ - required: t("workspace_settings.settings.general.errors.name.required"), - maxLength: { - value: 80, - message: t("workspace_settings.settings.general.errors.name.max_length"), - }, + validate: (value) => validateWorkspaceName(value, true), }} render={({ field: { value, onChange, ref } }) => ( )} /> + {errors.name &&

{errors.name.message}

}

diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e59acc5819..450acb4b27 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -36,6 +36,7 @@ export * from "./tab-indices"; export * from "./theme"; export { resolveGeneralTheme } from "./theme-legacy"; export * from "./url"; +export * from "./validation"; export * from "./work-item-filters"; export * from "./work-item"; export * from "./workspace"; diff --git a/packages/utils/src/validation.ts b/packages/utils/src/validation.ts new file mode 100644 index 0000000000..41c52aaa13 --- /dev/null +++ b/packages/utils/src/validation.ts @@ -0,0 +1,216 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +/** + * Input Validation Utilities + * Following OWASP Input Validation best practices using allowlist approach + * + * Security: Blocks injection-risk characters: < > ' " % # { } [ ] * ^ ! + * These patterns are designed to prevent XSS, SQL injection, template injection, + * and other security vulnerabilities while maintaining good UX + */ + +// ============================================================================= +// VALIDATION REGEX PATTERNS +// ============================================================================= + +/** + * Person Name Pattern (for first_name, last_name) + * Allows: Unicode letters (\p{L}), spaces, hyphens, apostrophes + * Use case: Accommodates international names like "José", "李明", "محمد", "Müller" + * Blocks: Injection-risk characters and special symbols + */ +export const PERSON_NAME_REGEX = /^[\p{L}\s'-]+$/u; + +/** + * Display Name Pattern (for display_name, usernames) + * Allows: Unicode letters (\p{L}), numbers (\p{N}), underscore, period, hyphen + * Use case: International usernames like "josé_123", "李明.dev", "müller-2024" + * Blocks: Spaces and injection-risk characters + */ +export const DISPLAY_NAME_REGEX = /^[\p{L}\p{N}_.-]+$/u; + +/** + * Company/Organization Name Pattern (for company_name, workspace names) + * Allows: Unicode letters (\p{L}), numbers (\p{N}), spaces, underscores, hyphens + * Use case: International business names like "Société Générale", "株式会社", "Müller GmbH" + * Blocks: Special punctuation and injection-risk chars + */ +export const COMPANY_NAME_REGEX = /^[\p{L}\p{N}\s_-]+$/u; + +/** + * URL Slug Pattern (for workspace slugs, URL-safe identifiers) + * Allows: Unicode letters (\p{L}), numbers (\p{N}), underscores, hyphens + * Use case: International URL-safe identifiers like "josé-workspace", "李明-project" + * Blocks: Spaces and special characters (URL encoding will handle Unicode in actual URLs) + */ +export const SLUG_REGEX = /^[\p{L}\p{N}_-]+$/u; + +// ============================================================================= +// VALIDATION FUNCTIONS +// ============================================================================= + +/** + * @description Validates person names (first name, last name) + * @param {string} name - Name to validate + * @returns {boolean | string} true if valid, error message if invalid + * @example + * validatePersonName("John") // returns true + * validatePersonName("O'Brien") // returns true + * validatePersonName("Jean-Paul") // returns true + * validatePersonName("John