diff --git a/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py index fbf67905167..7e592a9aaf9 100644 --- a/apps/api/plane/api/views/project.py +++ b/apps/api/plane/api/views/project.py @@ -18,7 +18,7 @@ from plane.db.models import ( Cycle, Intake, - IssueUserProperty, + ProjectUserProperty, Module, Project, DeployBoard, @@ -218,8 +218,6 @@ def post(self, request, slug): # 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 @@ def post(self, request, slug): 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 759f27ed6e6..96046187f9b 100644 --- a/apps/api/plane/app/serializers/__init__.py +++ b/apps/api/plane/app/serializers/__init__.py @@ -52,7 +52,7 @@ 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 5e3b93ab674..c8f530ba18c 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -18,7 +18,7 @@ Issue, IssueActivity, IssueComment, - IssueUserProperty, + ProjectUserProperty, IssueAssignee, IssueSubscriber, IssueLabel, @@ -346,9 +346,9 @@ class Meta: 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 1d809e248f5..1fd77412060 100644 --- a/apps/api/plane/app/urls/issue.py +++ b/apps/api/plane/app/urls/issue.py @@ -14,7 +14,7 @@ IssueReactionViewSet, IssueRelationViewSet, IssueSubscriberViewSet, - IssueUserDisplayPropertyEndpoint, + ProjectUserDisplayPropertyEndpoint, IssueViewSet, LabelViewSet, BulkArchiveIssuesEndpoint, @@ -208,13 +208,13 @@ 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 7a0e5cb3a28..88b739e4b37 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -114,7 +114,7 @@ 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 7a5e7dddf62..89fd9bbda91 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -34,7 +34,7 @@ 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 @@ IssueReaction, IssueRelation, IssueSubscriber, - IssueUserProperty, + ProjectUserProperty, ModuleIssue, Project, ProjectMember, @@ -723,23 +723,33 @@ def destroy(self, request, slug, project_id, pk=None): 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 34e13d472d8..e1d0c0c2a6e 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.db.models import ( UserFavorite, DeployBoard, + ProjectUserProperty, Intake, - IssueUserProperty, Project, ProjectIdentifier, ProjectMember, ProjectNetwork, State, DEFAULT_STATES, + UserFavorite, Workspace, WorkspaceMember, ) @@ -250,8 +251,6 @@ def create(self, request, slug): 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 @@ def create(self, request, slug): 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 cc5b3f4b577..f4ea9ba41a1 100644 --- a/apps/api/plane/app/views/project/invite.py +++ b/apps/api/plane/app/views/project/invite.py @@ -24,7 +24,7 @@ User, WorkspaceMember, Project, - IssueUserProperty, + ProjectUserProperty, ) from plane.db.models.project import ProjectNetwork from plane.utils.host import base_host @@ -160,9 +160,9 @@ def create(self, request, slug): 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 @@ def post(self, request, slug, project_id, pk): 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 3ab7061e15c..7c5e4f4f6fc 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.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 @@ def create(self, request, slug, project_id): # 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 @@ def create(self, request, slug, project_id): 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 d1c15a345c3..df69b1f4acc 100644 --- a/apps/api/plane/bgtasks/workspace_seed_task.py +++ b/apps/api/plane/bgtasks/workspace_seed_task.py @@ -21,7 +21,7 @@ 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 d9b46524c28..c04dbda7e7c 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 @@ WorkspaceMember, ProjectMember, Project, - IssueUserProperty, + ProjectUserProperty, ) @@ -47,27 +47,18 @@ def handle(self, *args: Any, **options: Any): 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 00000000000..9a18fbafca5 --- /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 00000000000..b9ac71d4709 --- /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 41fd32bd557..5d7267c2139 100644 --- a/apps/api/plane/db/models/__init__.py +++ b/apps/api/plane/db/models/__init__.py @@ -34,7 +34,6 @@ IssueLabel, IssueLink, IssueMention, - IssueUserProperty, IssueReaction, IssueRelation, IssueSequence, @@ -54,6 +53,7 @@ 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 5f6ce051d2a..68a4ae6dd0f 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -526,36 +526,6 @@ def __str__(self): 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 173ed43854a..16281025bb2 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -12,7 +12,6 @@ # 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"] - - # Project ordering - if smallest_sort_order is not None: - self.sort_order = smallest_sort_order - 10000 + 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") + + # 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 Meta: 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 38b0f51f3b5..9f05314cfee 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 plane.db.models import ( Project, ProjectMember, - IssueUserProperty, + ProjectUserProperty, State, WorkspaceMember, User, @@ -82,8 +82,8 @@ def test_create_project_valid_data(self, session_client, workspace, create_user) 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 @@ def test_create_project_with_project_lead(self, session_client, workspace, creat 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 57a61009436..5edc2396ed8 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 6df49cbea59..fbbe1807ccd 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 d6f67b108ce..c81d7e43368 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 04d62d1537c..8c378afa415 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 5915c7608d9..f4e29cecfc9 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 1768606c5e7..dbfc6817d3f 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 eb135868aae..0a2bbeb356c 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 422fac379fa..51f64c6a62a 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 9da12b57033..4258cf725ac 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 aa90541a957..40f22e07aea 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";