[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 <narayan3119@gmail.com>
Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
Co-authored-by: vamsikrishnamathala <matalav55@gmail.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
This commit is contained in:
Nikhil
2026-01-06 15:37:19 +05:30
committed by GitHub
parent 3d5e427894
commit ea1f92e0c6
27 changed files with 304 additions and 256 deletions

View File

@@ -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(
[

View File

@@ -52,7 +52,7 @@ from .issue import (
IssueCreateSerializer,
IssueActivitySerializer,
IssueCommentSerializer,
IssueUserPropertySerializer,
ProjectUserPropertySerializer,
IssueAssigneeSerializer,
LabelSerializer,
IssueSerializer,

View File

@@ -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"]

View File

@@ -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/<str:slug>/projects/<uuid:project_id>/user-properties/",
IssueUserDisplayPropertyEndpoint.as_view(),
ProjectUserDisplayPropertyEndpoint.as_view(),
name="project-issue-display-properties",
),
## IssueUserProperty End
## ProjectUserProperty End
## Issue Archives
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/",

View File

@@ -114,7 +114,7 @@ from .asset.v2 import (
from .issue.base import (
IssueListEndpoint,
IssueViewSet,
IssueUserDisplayPropertyEndpoint,
ProjectUserDisplayPropertyEndpoint,
BulkDeleteIssuesEndpoint,
DeletedIssuesListViewSet,
IssuePaginatedViewSet,

View File

@@ -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)

View File

@@ -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(
[

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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}"))

View File

@@ -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'),
),
]

View File

@@ -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),
]

View File

@@ -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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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):

View File

@@ -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({

View File

@@ -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

View File

@@ -28,26 +28,6 @@ export class IssueFiltersService extends APIService {
// });
// }
// project issue filters
async fetchProjectIssueFilters(workspaceSlug: string, projectId: string): Promise<IIssueFiltersResponse> {
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<IIssueFiltersResponse>
): Promise<any> {
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<IIssueFiltersResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/epics-user-properties/`)

View File

@@ -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<IProjectMemberPreferencesFullResponse> {
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<IProjectMemberPreferencesResponse> {
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();

View File

@@ -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<IProjectUserPropertiesResponse> {
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<any> {
await this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-views/`, data)
data: Partial<IProjectUserPropertiesResponse>
): Promise<IProjectUserPropertiesResponse> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-properties/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;

View File

@@ -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;

View File

@@ -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<string, TProjectMembership>;
};
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<TProjectMembership[]>;
fetchProjectMemberPreferences: (
workspaceSlug: string,
projectId: string,
memberId: string
) => Promise<IProjectMemberNavigationPreferences>;
fetchProjectUserProperties: (workspaceSlug: string, projectId: string) => Promise<IProjectUserPropertiesResponse>;
// update actions
updateProjectMemberPreferences: (
updateProjectUserProperties: (
workspaceSlug: string,
projectId: string,
memberId: string,
preferences: IProjectMemberNavigationPreferences
) => Promise<void>;
data: Partial<IProjectUserPropertiesResponse>
) => Promise<IProjectUserPropertiesResponse>;
// bulk operation actions
bulkAddMembersToProject: (
workspaceSlug: string,
@@ -91,8 +86,8 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
projectMemberMap: {
[projectId: string]: Record<string, TProjectMembership>;
} = {};
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<IProjectMemberNavigationPreferences> => {
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<IProjectUserPropertiesResponse> => {
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<void> => {
const previousPreferences = this.projectMemberPreferencesMap[projectId];
data: Partial<IProjectUserPropertiesResponse>
): Promise<IProjectUserPropertiesResponse> => {
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;

View File

@@ -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(() => {

View File

@@ -74,12 +74,6 @@ export interface IProjectLite {
logo_props: TLogoProps;
}
export type ProjectPreferences = {
pages: {
block_display: boolean;
};
};
export interface IProjectMap {
[id: string]: IProject;
}

View File

@@ -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";