Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4835fc478 | ||
|
|
2d34ef201c | ||
|
|
3122bd2e89 | ||
|
|
4d92d6b919 | ||
|
|
f35083bc4f | ||
|
|
557020cd47 | ||
|
|
5770cdeb5e | ||
|
|
f6f48881b0 | ||
|
|
d79883333c | ||
|
|
e206224746 | ||
|
|
378e896bf0 | ||
|
|
e3799c8a40 | ||
|
|
0d70397639 | ||
|
|
d2758fe5e6 | ||
|
|
1420b7e7d3 | ||
|
|
85e87ce595 | ||
|
|
854eacdaa8 |
@@ -13,7 +13,6 @@ from .user import (
|
||||
from .workspace import (
|
||||
WorkSpaceSerializer,
|
||||
WorkSpaceMemberSerializer,
|
||||
TeamSerializer,
|
||||
WorkSpaceMemberInviteSerializer,
|
||||
WorkspaceLiteSerializer,
|
||||
WorkspaceThemeSerializer,
|
||||
|
||||
@@ -6,11 +6,8 @@ from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from .user import UserLiteSerializer, UserAdminLiteSerializer
|
||||
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
Team,
|
||||
TeamMember,
|
||||
WorkspaceMemberInvite,
|
||||
WorkspaceTheme,
|
||||
WorkspaceUserProperties,
|
||||
@@ -97,52 +94,6 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
class TeamSerializer(BaseSerializer):
|
||||
members_detail = UserLiteSerializer(read_only=True, source="members", many=True)
|
||||
members = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def create(self, validated_data, **kwargs):
|
||||
if "members" in validated_data:
|
||||
members = validated_data.pop("members")
|
||||
workspace = self.context["workspace"]
|
||||
team = Team.objects.create(**validated_data, workspace=workspace)
|
||||
team_members = [
|
||||
TeamMember(member=member, team=team, workspace=workspace)
|
||||
for member in members
|
||||
]
|
||||
TeamMember.objects.bulk_create(team_members, batch_size=10)
|
||||
return team
|
||||
team = Team.objects.create(**validated_data)
|
||||
return team
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if "members" in validated_data:
|
||||
members = validated_data.pop("members")
|
||||
TeamMember.objects.filter(team=instance).delete()
|
||||
team_members = [
|
||||
TeamMember(member=member, team=instance, workspace=instance.workspace)
|
||||
for member in members
|
||||
]
|
||||
TeamMember.objects.bulk_create(team_members, batch_size=10)
|
||||
return super().update(instance, validated_data)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class WorkspaceThemeSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = WorkspaceTheme
|
||||
|
||||
@@ -7,7 +7,6 @@ from plane.app.views import (
|
||||
ProjectMemberViewSet,
|
||||
ProjectMemberUserEndpoint,
|
||||
ProjectJoinEndpoint,
|
||||
AddTeamToProjectEndpoint,
|
||||
ProjectUserViewsEndpoint,
|
||||
ProjectIdentifierEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
@@ -83,11 +82,6 @@ urlpatterns = [
|
||||
ProjectMemberViewSet.as_view({"post": "leave"}),
|
||||
name="project-member",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/team-invite/",
|
||||
AddTeamToProjectEndpoint.as_view(),
|
||||
name="projects",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/project-views/",
|
||||
ProjectUserViewsEndpoint.as_view(),
|
||||
|
||||
@@ -10,7 +10,6 @@ from plane.app.views import (
|
||||
WorkspaceMemberUserEndpoint,
|
||||
WorkspaceMemberUserViewsEndpoint,
|
||||
WorkSpaceAvailabilityCheckEndpoint,
|
||||
TeamMemberViewSet,
|
||||
UserLastProjectWithWorkspaceEndpoint,
|
||||
WorkspaceThemeViewSet,
|
||||
WorkspaceUserProfileStatsEndpoint,
|
||||
@@ -69,7 +68,9 @@ urlpatterns = [
|
||||
# user workspace invitations
|
||||
path(
|
||||
"users/me/workspaces/invitations/",
|
||||
UserWorkspaceInvitationsViewSet.as_view({"get": "list", "post": "create"}),
|
||||
UserWorkspaceInvitationsViewSet.as_view(
|
||||
{"get": "list", "post": "create"}
|
||||
),
|
||||
name="user-workspace-invitations",
|
||||
),
|
||||
path(
|
||||
@@ -100,23 +101,6 @@ urlpatterns = [
|
||||
WorkSpaceMemberViewSet.as_view({"post": "leave"}),
|
||||
name="leave-workspace-members",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/teams/",
|
||||
TeamMemberViewSet.as_view({"get": "list", "post": "create"}),
|
||||
name="workspace-team-members",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/teams/<uuid:pk>/",
|
||||
TeamMemberViewSet.as_view(
|
||||
{
|
||||
"put": "update",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
"get": "retrieve",
|
||||
}
|
||||
),
|
||||
name="workspace-team-members",
|
||||
),
|
||||
path(
|
||||
"users/last-visited-workspace/",
|
||||
UserLastProjectWithWorkspaceEndpoint.as_view(),
|
||||
|
||||
@@ -16,7 +16,6 @@ from .project.invite import (
|
||||
|
||||
from .project.member import (
|
||||
ProjectMemberViewSet,
|
||||
AddTeamToProjectEndpoint,
|
||||
ProjectMemberUserEndpoint,
|
||||
UserProjectRolesEndpoint,
|
||||
)
|
||||
@@ -49,7 +48,6 @@ from .workspace.favorite import (
|
||||
|
||||
from .workspace.member import (
|
||||
WorkSpaceMemberViewSet,
|
||||
TeamMemberViewSet,
|
||||
WorkspaceMemberUserEndpoint,
|
||||
WorkspaceProjectMemberEndpoint,
|
||||
WorkspaceMemberUserViewsEndpoint,
|
||||
@@ -88,8 +86,6 @@ from .cycle.base import (
|
||||
CycleFavoriteViewSet,
|
||||
TransferCycleIssueEndpoint,
|
||||
CycleUserPropertiesEndpoint,
|
||||
CycleViewSet,
|
||||
TransferCycleIssueEndpoint,
|
||||
CycleAnalyticsEndpoint,
|
||||
CycleProgressEndpoint,
|
||||
)
|
||||
@@ -206,6 +202,5 @@ from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
|
||||
|
||||
from .error_404 import custom_404_view
|
||||
|
||||
from .exporter.base import ExportIssuesEndpoint
|
||||
from .notification.base import MarkAllReadNotificationViewSet
|
||||
from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint
|
||||
|
||||
@@ -11,7 +11,6 @@ from plane.app.serializers import (
|
||||
)
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectBasePermission,
|
||||
ProjectMemberPermission,
|
||||
ProjectLitePermission,
|
||||
WorkspaceUserPermission,
|
||||
@@ -20,8 +19,6 @@ from plane.app.permissions import (
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
TeamMember,
|
||||
IssueUserProperty,
|
||||
WorkspaceMember,
|
||||
)
|
||||
@@ -86,7 +83,10 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
workspace_member_role = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=member, is_active=True
|
||||
).role
|
||||
if workspace_member_role in [20] and member_roles.get(member) in [5, 15]:
|
||||
if workspace_member_role in [20] and member_roles.get(member) in [
|
||||
5,
|
||||
15,
|
||||
]:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot add a user with role lower than the workspace role"
|
||||
@@ -94,7 +94,10 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if workspace_member_role in [5] and member_roles.get(member) in [15, 20]:
|
||||
if workspace_member_role in [5] and member_roles.get(member) in [
|
||||
15,
|
||||
20,
|
||||
]:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot add a user with role higher than the workspace role"
|
||||
@@ -132,7 +135,8 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
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"))
|
||||
if str(project_member.get("member_id"))
|
||||
== str(member.get("member_id"))
|
||||
]
|
||||
# Create a new project member
|
||||
bulk_project_members.append(
|
||||
@@ -141,7 +145,9 @@ 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),
|
||||
sort_order=(
|
||||
sort_order[0] - 10000 if len(sort_order) else 65535
|
||||
),
|
||||
)
|
||||
)
|
||||
# Create a new issue property
|
||||
@@ -232,7 +238,9 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
> requested_project_member.role
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot update a role that is higher than your own role"},
|
||||
{
|
||||
"error": "You cannot update a role that is higher than your own role"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -272,7 +280,9 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
# User cannot deactivate higher role
|
||||
if requesting_project_member.role < project_member.role:
|
||||
return Response(
|
||||
{"error": "You cannot remove a user having role higher than you"},
|
||||
{
|
||||
"error": "You cannot remove a user having role higher than you"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -293,7 +303,10 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
if (
|
||||
project_member.role == 20
|
||||
and not ProjectMember.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, role=20, is_active=True
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
role=20,
|
||||
is_active=True,
|
||||
).count()
|
||||
> 1
|
||||
):
|
||||
@@ -309,53 +322,6 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class AddTeamToProjectEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectBasePermission]
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
team_members = TeamMember.objects.filter(
|
||||
workspace__slug=slug, team__in=request.data.get("teams", [])
|
||||
).values_list("member", flat=True)
|
||||
|
||||
if len(team_members) == 0:
|
||||
return Response(
|
||||
{"error": "No such team exists"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
project_members = []
|
||||
issue_props = []
|
||||
for member in team_members:
|
||||
project_members.append(
|
||||
ProjectMember(
|
||||
project_id=project_id,
|
||||
member_id=member,
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
issue_props.append(
|
||||
IssueUserProperty(
|
||||
project_id=project_id,
|
||||
user_id=member,
|
||||
workspace=workspace,
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
|
||||
ProjectMember.objects.bulk_create(
|
||||
project_members, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
|
||||
_ = IssueUserProperty.objects.bulk_create(
|
||||
issue_props, batch_size=10, ignore_conflicts=True
|
||||
)
|
||||
|
||||
serializer = ProjectMemberSerializer(project_members, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class ProjectMemberUserEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
project_member = ProjectMember.objects.get(
|
||||
@@ -378,6 +344,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
|
||||
).values("project_id", "role")
|
||||
|
||||
project_members = {
|
||||
str(member["project_id"]): member["role"] for member in project_members
|
||||
str(member["project_id"]): member["role"]
|
||||
for member in project_members
|
||||
}
|
||||
return Response(project_members, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -353,6 +353,7 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView):
|
||||
workspace__slug=slug,
|
||||
created_at__date=request.data.get("date"),
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
actor_id=user_id,
|
||||
).select_related("actor", "workspace", "issue", "project")[:10000]
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
# Django imports
|
||||
from django.db.models import CharField, Count, Q, OuterRef, Subquery, IntegerField
|
||||
from django.db.models import (
|
||||
Count,
|
||||
Q,
|
||||
OuterRef,
|
||||
Subquery,
|
||||
IntegerField,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.functions import Cast
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import (
|
||||
WorkSpaceAdminPermission,
|
||||
WorkspaceEntityPermission,
|
||||
allow_permission,
|
||||
ROLE,
|
||||
@@ -17,8 +21,6 @@ from plane.app.permissions import (
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
ProjectMemberRoleSerializer,
|
||||
TeamSerializer,
|
||||
UserLiteSerializer,
|
||||
WorkspaceMemberAdminSerializer,
|
||||
WorkspaceMemberMeSerializer,
|
||||
WorkSpaceMemberSerializer,
|
||||
@@ -27,9 +29,6 @@ from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
Team,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
DraftIssue,
|
||||
)
|
||||
@@ -120,7 +119,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
if requesting_workspace_member.role < workspace_member.role:
|
||||
return Response(
|
||||
{"error": "You cannot remove a user having role higher than you"},
|
||||
{
|
||||
"error": "You cannot remove a user having role higher than you"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -147,7 +148,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
# Deactivate the users from the projects where the user is part of
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
|
||||
workspace__slug=slug,
|
||||
member_id=workspace_member.member_id,
|
||||
is_active=True,
|
||||
).update(is_active=False)
|
||||
|
||||
workspace_member.is_active = False
|
||||
@@ -161,7 +164,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
multiple=True,
|
||||
)
|
||||
@invalidate_cache(path="/api/users/me/settings/")
|
||||
@invalidate_cache(path="api/users/me/workspaces/", user=False, multiple=True)
|
||||
@invalidate_cache(
|
||||
path="api/users/me/workspaces/", user=False, multiple=True
|
||||
)
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
@@ -208,7 +213,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
# # Deactivate the users from the projects where the user is part of
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
|
||||
workspace__slug=slug,
|
||||
member_id=workspace_member.member_id,
|
||||
is_active=True,
|
||||
).update(is_active=False)
|
||||
|
||||
# # Deactivate the user
|
||||
@@ -272,7 +279,9 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
|
||||
project_members = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, project_id__in=project_ids, is_active=True
|
||||
).select_related("project", "member", "workspace")
|
||||
project_members = ProjectMemberRoleSerializer(project_members, many=True).data
|
||||
project_members = ProjectMemberRoleSerializer(
|
||||
project_members, many=True
|
||||
).data
|
||||
|
||||
project_members_dict = dict()
|
||||
|
||||
@@ -284,53 +293,3 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
|
||||
project_members_dict[str(project_id)].append(project_member)
|
||||
|
||||
return Response(project_members_dict, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TeamMemberViewSet(BaseViewSet):
|
||||
serializer_class = TeamSerializer
|
||||
model = Team
|
||||
permission_classes = [WorkSpaceAdminPermission]
|
||||
|
||||
search_fields = ["member__display_name", "member__first_name"]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "workspace__owner")
|
||||
.prefetch_related("members")
|
||||
)
|
||||
|
||||
def create(self, request, slug):
|
||||
members = list(
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member__id__in=request.data.get("members", []),
|
||||
is_active=True,
|
||||
)
|
||||
.annotate(member_str_id=Cast("member", output_field=CharField()))
|
||||
.distinct()
|
||||
.values_list("member_str_id", flat=True)
|
||||
)
|
||||
|
||||
if len(members) != len(request.data.get("members", [])):
|
||||
users = list(set(request.data.get("members", [])).difference(members))
|
||||
users = User.objects.filter(pk__in=users)
|
||||
|
||||
serializer = UserLiteSerializer(users, many=True)
|
||||
return Response(
|
||||
{
|
||||
"error": f"{len(users)} of the member(s) are not a part of the workspace",
|
||||
"members": serializer.data,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
serializer = TeamSerializer(data=request.data, context={"workspace": workspace})
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Python imports
|
||||
import json
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
@@ -16,8 +18,9 @@ from plane.db.models import (
|
||||
IssueComment,
|
||||
IssueActivity,
|
||||
UserNotificationPreference,
|
||||
ProjectMember
|
||||
ProjectMember,
|
||||
)
|
||||
from django.db.models import Subquery
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
@@ -95,7 +98,8 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
||||
).exists()
|
||||
and not Issue.objects.filter(
|
||||
project_id=project_id, pk=issue_id, created_by_id=mention_id
|
||||
).exists() and ProjectMember.objects.filter(
|
||||
).exists()
|
||||
and ProjectMember.objects.filter(
|
||||
project_id=project_id, member_id=mention_id, is_active=True
|
||||
).exists()
|
||||
):
|
||||
@@ -242,14 +246,19 @@ def notifications(
|
||||
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
|
||||
"""
|
||||
|
||||
# get the list of active project members
|
||||
project_members = ProjectMember.objects.filter(
|
||||
project_id=project_id, is_active=True
|
||||
).values_list("member_id", flat=True)
|
||||
|
||||
# Get new mentions from the newer instance
|
||||
new_mentions = get_new_mentions(
|
||||
requested_instance=requested_data, current_instance=current_instance
|
||||
)
|
||||
new_mentions = list(ProjectMember.objects.filter(
|
||||
project_id=project_id, member_id__in=new_mentions, is_active=True
|
||||
).values_list("member_id", flat=True))
|
||||
new_mentions = [str(member_id) for member_id in new_mentions]
|
||||
|
||||
new_mentions = [
|
||||
str(mention) for mention in new_mentions if mention in set(project_members)
|
||||
]
|
||||
removed_mention = get_removed_mentions(
|
||||
requested_instance=requested_data, current_instance=current_instance
|
||||
)
|
||||
@@ -280,6 +289,11 @@ def notifications(
|
||||
new_value=issue_comment_new_value,
|
||||
)
|
||||
comment_mentions = comment_mentions + new_comment_mentions
|
||||
comment_mentions = [
|
||||
mention
|
||||
for mention in comment_mentions
|
||||
if UUID(mention) in set(project_members)
|
||||
]
|
||||
|
||||
comment_mention_subscribers = extract_mentions_as_subscribers(
|
||||
project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions
|
||||
@@ -293,7 +307,11 @@ def notifications(
|
||||
|
||||
# ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id, project__project_projectmember__is_active=True,)
|
||||
IssueSubscriber.objects.filter(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
subscriber__in=Subquery(project_members),
|
||||
)
|
||||
.exclude(
|
||||
subscriber_id__in=list(new_mentions + comment_mentions + [actor_id])
|
||||
)
|
||||
@@ -314,7 +332,9 @@ def notifications(
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
issue_assignees = IssueAssignee.objects.filter(
|
||||
issue_id=issue_id, project_id=project_id
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
assignee__in=Subquery(project_members),
|
||||
).values_list("assignee", flat=True)
|
||||
|
||||
issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)})
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
# Generated by Django 4.2.15 on 2024-11-27 09:07
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import plane.db.models.webhook
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0085_intake_intakeissue_remove_inboxissue_created_by_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="IssueVersion",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"deleted_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Deleted At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("parent", models.UUIDField(blank=True, null=True)),
|
||||
("state", models.UUIDField(blank=True, null=True)),
|
||||
("estimate_point", models.UUIDField(blank=True, null=True)),
|
||||
("name", models.CharField(max_length=255, verbose_name="Issue Name")),
|
||||
("description", models.JSONField(blank=True, default=dict)),
|
||||
("description_html", models.TextField(blank=True, default="<p></p>")),
|
||||
("description_stripped", models.TextField(blank=True, null=True)),
|
||||
("description_binary", models.BinaryField(null=True)),
|
||||
(
|
||||
"priority",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("urgent", "Urgent"),
|
||||
("high", "High"),
|
||||
("medium", "Medium"),
|
||||
("low", "Low"),
|
||||
("none", "None"),
|
||||
],
|
||||
default="none",
|
||||
max_length=30,
|
||||
verbose_name="Issue Priority",
|
||||
),
|
||||
),
|
||||
("start_date", models.DateField(blank=True, null=True)),
|
||||
("target_date", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"sequence_id",
|
||||
models.IntegerField(default=1, verbose_name="Issue Sequence ID"),
|
||||
),
|
||||
("sort_order", models.FloatField(default=65535)),
|
||||
("completed_at", models.DateTimeField(null=True)),
|
||||
("archived_at", models.DateField(null=True)),
|
||||
("is_draft", models.BooleanField(default=False)),
|
||||
(
|
||||
"external_source",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
"external_id",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
("type", models.UUIDField(blank=True, null=True)),
|
||||
(
|
||||
"last_saved_at",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
("owned_by", models.UUIDField()),
|
||||
(
|
||||
"assignees",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
(
|
||||
"labels",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
("cycle", models.UUIDField(blank=True, null=True)),
|
||||
(
|
||||
"modules",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
("properties", models.JSONField(default=dict)),
|
||||
("meta", models.JSONField(default=dict)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"issue",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="versions",
|
||||
to="db.issue",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_%(class)s",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Issue Version",
|
||||
"verbose_name_plural": "Issue Versions",
|
||||
"db_table": "issue_versions",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="teampage",
|
||||
unique_together=None,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="teampage",
|
||||
name="created_by",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="teampage",
|
||||
name="page",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="teampage",
|
||||
name="team",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="teampage",
|
||||
name="updated_by",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="teampage",
|
||||
name="workspace",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="page",
|
||||
name="teams",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="team",
|
||||
name="members",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="entity_identifier",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="webhook",
|
||||
name="is_internal",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="fileasset",
|
||||
name="entity_type",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="webhook",
|
||||
name="url",
|
||||
field=models.URLField(
|
||||
max_length=1024,
|
||||
validators=[
|
||||
plane.db.models.webhook.validate_schema,
|
||||
plane.db.models.webhook.validate_domain,
|
||||
],
|
||||
),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="TeamMember",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="TeamPage",
|
||||
),
|
||||
]
|
||||
@@ -61,8 +61,6 @@ from .user import Account, Profile, User
|
||||
from .view import IssueView
|
||||
from .webhook import Webhook, WebhookLog
|
||||
from .workspace import (
|
||||
Team,
|
||||
TeamMember,
|
||||
Workspace,
|
||||
WorkspaceBaseModel,
|
||||
WorkspaceMember,
|
||||
|
||||
@@ -44,25 +44,44 @@ class FileAsset(BaseModel):
|
||||
"db.User", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
draft_issue = models.ForeignKey(
|
||||
"db.DraftIssue", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
"db.DraftIssue",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"db.Project", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
"db.Project",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
)
|
||||
comment = models.ForeignKey(
|
||||
"db.IssueComment", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
"db.IssueComment",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
page = models.ForeignKey(
|
||||
"db.Page", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
)
|
||||
entity_type = models.CharField(
|
||||
max_length=255, choices=EntityTypeContext.choices, null=True, blank=True
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
entity_identifier = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
|
||||
@@ -9,11 +9,12 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from django import apps
|
||||
|
||||
# Module imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
from plane.db.mixins import SoftDeletionManager
|
||||
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from .project import ProjectBaseModel
|
||||
|
||||
|
||||
@@ -656,3 +657,126 @@ class IssueVote(ProjectBaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.actor.email}"
|
||||
|
||||
|
||||
class IssueVersion(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="versions",
|
||||
)
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
("high", "High"),
|
||||
("medium", "Medium"),
|
||||
("low", "Low"),
|
||||
("none", "None"),
|
||||
)
|
||||
parent = models.UUIDField(blank=True, null=True)
|
||||
state = models.UUIDField(blank=True, null=True)
|
||||
estimate_point = models.UUIDField(blank=True, null=True)
|
||||
name = models.CharField(max_length=255, verbose_name="Issue Name")
|
||||
description = models.JSONField(blank=True, default=dict)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_binary = models.BinaryField(null=True)
|
||||
priority = models.CharField(
|
||||
max_length=30,
|
||||
choices=PRIORITY_CHOICES,
|
||||
verbose_name="Issue Priority",
|
||||
default="none",
|
||||
)
|
||||
start_date = models.DateField(null=True, blank=True)
|
||||
target_date = models.DateField(null=True, blank=True)
|
||||
sequence_id = models.IntegerField(
|
||||
default=1, verbose_name="Issue Sequence ID"
|
||||
)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
completed_at = models.DateTimeField(null=True)
|
||||
archived_at = models.DateField(null=True)
|
||||
is_draft = models.BooleanField(default=False)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
type = models.UUIDField(blank=True, null=True)
|
||||
last_saved_at = models.DateTimeField(default=timezone.now)
|
||||
owned_by = models.UUIDField()
|
||||
assignees = ArrayField(
|
||||
models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
labels = ArrayField(
|
||||
models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
cycle = models.UUIDField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
modules = ArrayField(
|
||||
models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
properties = models.JSONField(default=dict)
|
||||
meta = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Version"
|
||||
verbose_name_plural = "Issue Versions"
|
||||
db_table = "issue_versions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
@classmethod
|
||||
def log_issue_version(cls, issue, user):
|
||||
try:
|
||||
"""
|
||||
Log the issue version
|
||||
"""
|
||||
|
||||
Module = apps.get_model("db.Module")
|
||||
CycleIssue = apps.get_model("db.CycleIssue")
|
||||
|
||||
cycle_issue = CycleIssue.objects.filter(
|
||||
issue=issue,
|
||||
).first()
|
||||
|
||||
cls.objects.create(
|
||||
issue=issue,
|
||||
parent=issue.parent,
|
||||
state=issue.state,
|
||||
point=issue.point,
|
||||
estimate_point=issue.estimate_point,
|
||||
name=issue.name,
|
||||
description=issue.description,
|
||||
description_html=issue.description_html,
|
||||
description_stripped=issue.description_stripped,
|
||||
description_binary=issue.description_binary,
|
||||
priority=issue.priority,
|
||||
start_date=issue.start_date,
|
||||
target_date=issue.target_date,
|
||||
sequence_id=issue.sequence_id,
|
||||
sort_order=issue.sort_order,
|
||||
completed_at=issue.completed_at,
|
||||
archived_at=issue.archived_at,
|
||||
is_draft=issue.is_draft,
|
||||
external_source=issue.external_source,
|
||||
external_id=issue.external_id,
|
||||
type=issue.type,
|
||||
last_saved_at=issue.last_saved_at,
|
||||
assignees=issue.assignees,
|
||||
labels=issue.labels,
|
||||
cycle=cycle_issue.cycle if cycle_issue else None,
|
||||
modules=Module.objects.filter(issue=issue).values_list(
|
||||
"id", flat=True
|
||||
),
|
||||
owned_by=user,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
|
||||
@@ -50,9 +50,6 @@ class Page(BaseModel):
|
||||
projects = models.ManyToManyField(
|
||||
"db.Project", related_name="pages", through="db.ProjectPage"
|
||||
)
|
||||
teams = models.ManyToManyField(
|
||||
"db.Team", related_name="pages", through="db.TeamPage"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page"
|
||||
@@ -160,32 +157,6 @@ class ProjectPage(BaseModel):
|
||||
return f"{self.project.name} {self.page.name}"
|
||||
|
||||
|
||||
class TeamPage(BaseModel):
|
||||
team = models.ForeignKey(
|
||||
"db.Team", on_delete=models.CASCADE, related_name="team_pages"
|
||||
)
|
||||
page = models.ForeignKey(
|
||||
"db.Page", on_delete=models.CASCADE, related_name="team_pages"
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="team_pages"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["team", "page", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["team", "page"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="team_page_unique_team_page_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Team Page"
|
||||
verbose_name_plural = "Team Pages"
|
||||
db_table = "team_pages"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class PageVersion(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="page_versions"
|
||||
|
||||
@@ -29,9 +29,13 @@ def validate_domain(value):
|
||||
|
||||
class Webhook(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks"
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_webhooks",
|
||||
)
|
||||
url = models.URLField(
|
||||
validators=[validate_schema, validate_domain], max_length=1024
|
||||
)
|
||||
url = models.URLField(validators=[validate_schema, validate_domain])
|
||||
is_active = models.BooleanField(default=True)
|
||||
secret_key = models.CharField(max_length=255, default=generate_token)
|
||||
project = models.BooleanField(default=False)
|
||||
@@ -39,6 +43,7 @@ class Webhook(BaseModel):
|
||||
module = models.BooleanField(default=False)
|
||||
cycle = models.BooleanField(default=False)
|
||||
issue_comment = models.BooleanField(default=False)
|
||||
is_internal = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.slug} {self.url}"
|
||||
|
||||
@@ -102,7 +102,12 @@ def get_default_display_properties():
|
||||
|
||||
|
||||
def get_issue_props():
|
||||
return {"subscribed": True, "assigned": True, "created": True, "all_issues": True}
|
||||
return {
|
||||
"subscribed": True,
|
||||
"assigned": True,
|
||||
"created": True,
|
||||
"all_issues": True,
|
||||
}
|
||||
|
||||
|
||||
def slug_validator(value):
|
||||
@@ -131,7 +136,9 @@ class Workspace(BaseModel):
|
||||
max_length=48, db_index=True, unique=True, validators=[slug_validator]
|
||||
)
|
||||
organization_size = models.CharField(max_length=20, blank=True, null=True)
|
||||
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
|
||||
timezone = models.CharField(
|
||||
max_length=255, default="UTC", choices=TIMEZONE_CHOICES
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the Workspace"""
|
||||
@@ -160,7 +167,10 @@ class WorkspaceBaseModel(BaseModel):
|
||||
"db.Workspace", models.CASCADE, related_name="workspace_%(class)s"
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"db.Project", models.CASCADE, related_name="project_%(class)s", null=True
|
||||
"db.Project",
|
||||
models.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -174,7 +184,9 @@ class WorkspaceBaseModel(BaseModel):
|
||||
|
||||
class WorkspaceMember(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member"
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_member",
|
||||
)
|
||||
member = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@@ -209,7 +221,9 @@ class WorkspaceMember(BaseModel):
|
||||
|
||||
class WorkspaceMemberInvite(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite"
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_member_invite",
|
||||
)
|
||||
email = models.CharField(max_length=255)
|
||||
accepted = models.BooleanField(default=False)
|
||||
@@ -239,13 +253,6 @@ class WorkspaceMemberInvite(BaseModel):
|
||||
class Team(BaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="Team Name")
|
||||
description = models.TextField(verbose_name="Team Description", blank=True)
|
||||
members = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
related_name="members",
|
||||
through="TeamMember",
|
||||
through_fields=("team", "member"),
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
Workspace, on_delete=models.CASCADE, related_name="workspace_team"
|
||||
)
|
||||
@@ -270,40 +277,15 @@ class Team(BaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class TeamMember(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
Workspace, on_delete=models.CASCADE, related_name="team_member"
|
||||
)
|
||||
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="team_member")
|
||||
member = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="team_member"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.team.name
|
||||
|
||||
class Meta:
|
||||
unique_together = ["team", "member", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["team", "member"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="team_member_unique_team_member_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Team Member"
|
||||
verbose_name_plural = "Team Members"
|
||||
db_table = "team_members"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class WorkspaceTheme(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="themes"
|
||||
)
|
||||
name = models.CharField(max_length=300)
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes"
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="themes",
|
||||
)
|
||||
colors = models.JSONField(default=dict)
|
||||
|
||||
@@ -338,7 +320,9 @@ class WorkspaceUserProperties(BaseModel):
|
||||
)
|
||||
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)
|
||||
display_properties = models.JSONField(
|
||||
default=get_default_display_properties
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "user", "deleted_at"]
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
"linkifyjs": "^4.1.3",
|
||||
"lowlight": "^3.0.0",
|
||||
"lucide-react": "^0.378.0",
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"prosemirror-utils": "^1.2.2",
|
||||
"react-moveable": "^0.54.2",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
|
||||
export const CoreEditorAdditionalExtensions = (): Extensions => [];
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./extensions";
|
||||
export * from "./read-only-extensions";
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
|
||||
export const CoreReadOnlyEditorAdditionalExtensions = (): Extensions => [];
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
|
||||
export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = [];
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./core";
|
||||
export * from "./document-extensions";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type TEditorAdditionalCommands = never;
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./editor";
|
||||
export * from "./issue-embed";
|
||||
|
||||
@@ -31,6 +31,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
serverHandler,
|
||||
tabIndex,
|
||||
user,
|
||||
has_enabled_smooth_cursor = true,
|
||||
} = props;
|
||||
|
||||
const extensions = [];
|
||||
@@ -45,6 +46,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
// use document editor
|
||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
|
||||
onTransaction,
|
||||
has_enabled_smooth_cursor,
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
embedHandler,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { EditorContentWrapper } from "./editor-content";
|
||||
type Props = IEditorProps & {
|
||||
children?: (editor: Editor) => React.ReactNode;
|
||||
extensions: Extension<any, any>[];
|
||||
has_enabled_smooth_cursor: boolean;
|
||||
};
|
||||
|
||||
export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
@@ -26,10 +27,11 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
initialValue,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
has_enabled_smooth_cursor,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
onTransaction,
|
||||
handleEditorReady,
|
||||
autofocus,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
@@ -42,12 +44,13 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
has_enabled_smooth_cursor,
|
||||
id,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
onTransaction,
|
||||
handleEditorReady,
|
||||
autofocus,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { EnterKeyExtension } from "@/extensions";
|
||||
import { EditorRefApi, ILiteTextEditor } from "@/types";
|
||||
|
||||
const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
|
||||
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [], has_enabled_smooth_cursor } = props;
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
@@ -17,7 +17,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
[externalExtensions, disabledExtensions, onEnterKeyPress]
|
||||
);
|
||||
|
||||
return <EditorWrapper {...props} extensions={extensions} />;
|
||||
return <EditorWrapper {...props} extensions={extensions} has_enabled_smooth_cursor={has_enabled_smooth_cursor} />;
|
||||
};
|
||||
|
||||
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
|
||||
|
||||
@@ -13,6 +13,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
dragDropEnabled,
|
||||
bubbleMenuEnabled = true,
|
||||
extensions: externalExtensions = [],
|
||||
has_enabled_smooth_cursor,
|
||||
} = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
@@ -31,7 +32,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
}, [dragDropEnabled, disabledExtensions, externalExtensions]);
|
||||
|
||||
return (
|
||||
<EditorWrapper {...props} extensions={getExtensions()}>
|
||||
<EditorWrapper {...props} extensions={getExtensions()} has_enabled_smooth_cursor={has_enabled_smooth_cursor}>
|
||||
{(editor) => <>{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}</>}
|
||||
</EditorWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { useState } from "react";
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
// local components
|
||||
import { CalloutBlockColorSelector } from "./color-selector";
|
||||
import { CalloutBlockLogoSelector } from "./logo-selector";
|
||||
// types
|
||||
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
|
||||
// utils
|
||||
import { updateStoredBackgroundColor } from "./utils";
|
||||
|
||||
type Props = NodeViewProps & {
|
||||
node: NodeViewProps["node"] & {
|
||||
attrs: TCalloutBlockAttributes;
|
||||
};
|
||||
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
|
||||
};
|
||||
|
||||
export const CustomCalloutBlock: React.FC<Props> = (props) => {
|
||||
const { editor, node, updateAttributes } = props;
|
||||
// states
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
// derived values
|
||||
const activeBackgroundColor = COLORS_LIST.find((c) => node.attrs["data-background"] === c.key)?.backgroundColor;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className="editor-callout-component group/callout-node relative bg-custom-background-90 rounded-lg text-custom-text-100 p-4 my-2 flex items-start gap-4 transition-colors duration-500 break-words"
|
||||
style={{
|
||||
backgroundColor: activeBackgroundColor,
|
||||
}}
|
||||
>
|
||||
<CalloutBlockLogoSelector
|
||||
blockAttributes={node.attrs}
|
||||
disabled={!editor.isEditable}
|
||||
isOpen={isEmojiPickerOpen}
|
||||
handleOpen={(val) => setIsEmojiPickerOpen(val)}
|
||||
updateAttributes={updateAttributes}
|
||||
/>
|
||||
<CalloutBlockColorSelector
|
||||
disabled={!editor.isEditable}
|
||||
isOpen={isColorPickerOpen}
|
||||
toggleDropdown={() => setIsColorPickerOpen((prev) => !prev)}
|
||||
onSelect={(val) => {
|
||||
updateAttributes({
|
||||
[EAttributeNames.BACKGROUND]: val,
|
||||
});
|
||||
updateStoredBackgroundColor(val);
|
||||
}}
|
||||
/>
|
||||
<NodeViewContent as="div" className="w-full break-words" />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Ban, ChevronDown } from "lucide-react";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
|
||||
type Props = {
|
||||
disabled: boolean;
|
||||
isOpen: boolean;
|
||||
onSelect: (color: string | null) => void;
|
||||
toggleDropdown: () => void;
|
||||
};
|
||||
|
||||
export const CalloutBlockColorSelector: React.FC<Props> = (props) => {
|
||||
const { disabled, isOpen, onSelect, toggleDropdown } = props;
|
||||
|
||||
const handleColorSelect = (val: string | null) => {
|
||||
onSelect(val);
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("opacity-0 pointer-events-none absolute top-2 right-2 z-10 transition-opacity", {
|
||||
"group-hover/callout-node:opacity-100 group-hover/callout-node:pointer-events-auto": !disabled,
|
||||
"opacity-100 pointer-events-auto": isOpen,
|
||||
})}
|
||||
contentEditable={false}
|
||||
>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
toggleDropdown();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1 h-full whitespace-nowrap py-1 px-2.5 text-sm font-medium text-custom-text-300 hover:bg-white/10 active:bg-custom-background-80 rounded transition-colors",
|
||||
{
|
||||
"bg-white/10": isOpen,
|
||||
}
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span>Color</span>
|
||||
<ChevronDown className="flex-shrink-0 size-3" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<section className="absolute top-full right-0 z-10 mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{COLORS_LIST.map((color) => (
|
||||
<button
|
||||
key={color.key}
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: color.backgroundColor,
|
||||
}}
|
||||
onClick={() => handleColorSelect(color.key)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||
onClick={() => handleColorSelect(null)}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { Node as NodeType } from "@tiptap/pm/model";
|
||||
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
|
||||
// types
|
||||
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
|
||||
// utils
|
||||
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils";
|
||||
|
||||
// Extend Tiptap's Commands interface
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
calloutComponent: {
|
||||
insertCallout: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomCalloutExtensionConfig = Node.create({
|
||||
name: "calloutComponent",
|
||||
group: "block",
|
||||
content: "block+",
|
||||
|
||||
addAttributes() {
|
||||
const attributes = {
|
||||
// Reduce instead of map to accumulate the attributes directly into an object
|
||||
...Object.values(EAttributeNames).reduce((acc, value) => {
|
||||
acc[value] = {
|
||||
default: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[value],
|
||||
};
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
return attributes;
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: MarkdownSerializerState, node: NodeType) {
|
||||
const attrs = node.attrs as TCalloutBlockAttributes;
|
||||
const logoInUse = attrs["data-logo-in-use"];
|
||||
// add callout logo
|
||||
if (logoInUse === "emoji") {
|
||||
state.write(
|
||||
`> <img src="${attrs["data-emoji-url"]}" alt="${attrs["data-emoji-unicode"]}" width="30px" />\n`
|
||||
);
|
||||
} else {
|
||||
state.write(`> <icon>${attrs["data-icon-name"]} icon</icon>\n`);
|
||||
}
|
||||
// add an empty line after the logo
|
||||
state.write("> \n");
|
||||
// add '> ' before each line of the callout content
|
||||
state.wrapBlock("> ", null, node, () => state.renderContent(node));
|
||||
state.closeBlock(node);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[${EAttributeNames.BLOCK_TYPE}="${DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[EAttributeNames.BLOCK_TYPE]}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// Render HTML for the callout node
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["div", mergeAttributes(HTMLAttributes), 0];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { findParentNodeClosestToPos, Predicate, ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomCalloutBlock } from "@/extensions";
|
||||
// helpers
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// config
|
||||
import { CustomCalloutExtensionConfig } from "./extension-config";
|
||||
// utils
|
||||
import { getStoredBackgroundColor, getStoredLogo } from "./utils";
|
||||
|
||||
export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertCallout:
|
||||
() =>
|
||||
({ commands }) => {
|
||||
// get stored logo values and background color from the local storage
|
||||
const storedLogoValues = getStoredLogo();
|
||||
const storedBackgroundValue = getStoredBackgroundColor();
|
||||
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
attrs: {
|
||||
...storedLogoValues,
|
||||
"data-background": storedBackgroundValue,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
try {
|
||||
const isParentNodeCallout: Predicate = (node) => node.type === this.type;
|
||||
const parentNodeDetails = findParentNodeClosestToPos($from, isParentNodeCallout);
|
||||
// Check if selection is empty and at the beginning of the callout
|
||||
if (empty && parentNodeDetails) {
|
||||
const isCursorAtCalloutBeginning = $from.pos === parentNodeDetails.start + 1;
|
||||
if (parentNodeDetails.node.content.size > 2 && isCursorAtCalloutBeginning) {
|
||||
editor.commands.setTextSelection(parentNodeDetails.pos - 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in performing backspace action on callout", error);
|
||||
}
|
||||
return false; // Allow the default behavior if conditions are not met
|
||||
},
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomCalloutBlock);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./block";
|
||||
export * from "./extension";
|
||||
export * from "./read-only-extension";
|
||||
@@ -0,0 +1,97 @@
|
||||
// plane helpers
|
||||
import { convertHexEmojiToDecimal } from "@plane/helpers";
|
||||
// plane ui
|
||||
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// types
|
||||
import { TCalloutBlockAttributes } from "./types";
|
||||
// utils
|
||||
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES, updateStoredLogo } from "./utils";
|
||||
|
||||
type Props = {
|
||||
blockAttributes: TCalloutBlockAttributes;
|
||||
disabled: boolean;
|
||||
handleOpen: (val: boolean) => void;
|
||||
isOpen: boolean;
|
||||
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
|
||||
};
|
||||
|
||||
export const CalloutBlockLogoSelector: React.FC<Props> = (props) => {
|
||||
const { blockAttributes, disabled, handleOpen, isOpen, updateAttributes } = props;
|
||||
|
||||
const logoValue: TEmojiLogoProps = {
|
||||
in_use: blockAttributes["data-logo-in-use"],
|
||||
icon: {
|
||||
color: blockAttributes["data-icon-color"],
|
||||
name: blockAttributes["data-icon-name"],
|
||||
},
|
||||
emoji: {
|
||||
value: blockAttributes["data-emoji-unicode"]?.toString(),
|
||||
url: blockAttributes["data-emoji-url"],
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div contentEditable={false}>
|
||||
<EmojiIconPicker
|
||||
closeOnSelect={false}
|
||||
isOpen={isOpen}
|
||||
handleToggle={handleOpen}
|
||||
className="flex-shrink-0 grid place-items-center"
|
||||
buttonClassName={cn("flex-shrink-0 size-8 grid place-items-center rounded-lg", {
|
||||
"hover:bg-white/10": !disabled,
|
||||
})}
|
||||
label={<Logo logo={logoValue} size={18} type="lucide" />}
|
||||
onChange={(val) => {
|
||||
// construct the new logo value based on the type of value
|
||||
let newLogoValue: Partial<TCalloutBlockAttributes> = {};
|
||||
let newLogoValueToStoreInLocalStorage: TEmojiLogoProps = {
|
||||
in_use: "emoji",
|
||||
emoji: {
|
||||
value: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
|
||||
url: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
|
||||
},
|
||||
};
|
||||
if (val.type === "emoji") {
|
||||
newLogoValue = {
|
||||
"data-emoji-unicode": convertHexEmojiToDecimal(val.value.unified),
|
||||
"data-emoji-url": val.value.imageUrl,
|
||||
};
|
||||
newLogoValueToStoreInLocalStorage = {
|
||||
in_use: "emoji",
|
||||
emoji: {
|
||||
value: convertHexEmojiToDecimal(val.value.unified),
|
||||
url: val.value.imageUrl,
|
||||
},
|
||||
};
|
||||
} else if (val.type === "icon") {
|
||||
newLogoValue = {
|
||||
"data-icon-name": val.value.name,
|
||||
"data-icon-color": val.value.color,
|
||||
};
|
||||
newLogoValueToStoreInLocalStorage = {
|
||||
in_use: "icon",
|
||||
icon: {
|
||||
name: val.value.name,
|
||||
color: val.value.color,
|
||||
},
|
||||
};
|
||||
}
|
||||
// update node attributes
|
||||
updateAttributes({
|
||||
"data-logo-in-use": val.type,
|
||||
...newLogoValue,
|
||||
});
|
||||
// update stored logo in local storage
|
||||
updateStoredLogo(newLogoValueToStoreInLocalStorage);
|
||||
handleOpen(false);
|
||||
}}
|
||||
defaultIconColor={logoValue?.in_use && logoValue.in_use === "icon" ? logoValue?.icon?.color : undefined}
|
||||
defaultOpen={logoValue.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON}
|
||||
disabled={disabled}
|
||||
searchDisabled
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomCalloutBlock } from "@/extensions";
|
||||
// config
|
||||
import { CustomCalloutExtensionConfig } from "./extension-config";
|
||||
|
||||
export const CustomCalloutReadOnlyExtension = CustomCalloutExtensionConfig.extend({
|
||||
selectable: false,
|
||||
draggable: false,
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomCalloutBlock);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export enum EAttributeNames {
|
||||
ICON_COLOR = "data-icon-color",
|
||||
ICON_NAME = "data-icon-name",
|
||||
EMOJI_UNICODE = "data-emoji-unicode",
|
||||
EMOJI_URL = "data-emoji-url",
|
||||
LOGO_IN_USE = "data-logo-in-use",
|
||||
BACKGROUND = "data-background",
|
||||
BLOCK_TYPE = "data-block-type",
|
||||
}
|
||||
|
||||
export type TCalloutBlockIconAttributes = {
|
||||
[EAttributeNames.ICON_COLOR]: string | undefined;
|
||||
[EAttributeNames.ICON_NAME]: string | undefined;
|
||||
};
|
||||
|
||||
export type TCalloutBlockEmojiAttributes = {
|
||||
[EAttributeNames.EMOJI_UNICODE]: string | undefined;
|
||||
[EAttributeNames.EMOJI_URL]: string | undefined;
|
||||
};
|
||||
|
||||
export type TCalloutBlockAttributes = {
|
||||
[EAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
|
||||
[EAttributeNames.BACKGROUND]: string;
|
||||
[EAttributeNames.BLOCK_TYPE]: "callout-component";
|
||||
} & TCalloutBlockIconAttributes &
|
||||
TCalloutBlockEmojiAttributes;
|
||||
@@ -0,0 +1,85 @@
|
||||
// plane helpers
|
||||
import { sanitizeHTML } from "@plane/helpers";
|
||||
// plane ui
|
||||
import { TEmojiLogoProps } from "@plane/ui";
|
||||
// types
|
||||
import {
|
||||
EAttributeNames,
|
||||
TCalloutBlockAttributes,
|
||||
TCalloutBlockEmojiAttributes,
|
||||
TCalloutBlockIconAttributes,
|
||||
} from "./types";
|
||||
|
||||
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
|
||||
"data-logo-in-use": "emoji",
|
||||
"data-icon-color": null,
|
||||
"data-icon-name": null,
|
||||
"data-emoji-unicode": "128161",
|
||||
"data-emoji-url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png",
|
||||
"data-background": null,
|
||||
"data-block-type": "callout-component",
|
||||
};
|
||||
|
||||
type TStoredLogoValue = Pick<TCalloutBlockAttributes, EAttributeNames.LOGO_IN_USE> &
|
||||
(TCalloutBlockEmojiAttributes | TCalloutBlockIconAttributes);
|
||||
|
||||
// function to get the stored logo from local storage
|
||||
export const getStoredLogo = (): TStoredLogoValue => {
|
||||
const fallBackValues: TStoredLogoValue = {
|
||||
"data-logo-in-use": "emoji",
|
||||
"data-emoji-unicode": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
|
||||
"data-emoji-url": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo"));
|
||||
if (storedData) {
|
||||
let parsedData: TEmojiLogoProps;
|
||||
try {
|
||||
parsedData = JSON.parse(storedData);
|
||||
} catch (error) {
|
||||
console.error(`Error parsing stored callout logo, stored value- ${storedData}`, error);
|
||||
localStorage.removeItem("editor-calloutComponent-logo");
|
||||
return fallBackValues;
|
||||
}
|
||||
if (parsedData.in_use === "emoji" && parsedData.emoji?.value) {
|
||||
return {
|
||||
"data-logo-in-use": "emoji",
|
||||
"data-emoji-unicode": parsedData.emoji.value || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
|
||||
"data-emoji-url": parsedData.emoji.url || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
|
||||
};
|
||||
}
|
||||
if (parsedData.in_use === "icon" && parsedData.icon?.name) {
|
||||
return {
|
||||
"data-logo-in-use": "icon",
|
||||
"data-icon-name": parsedData.icon.name || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-name"],
|
||||
"data-icon-color": parsedData.icon.color || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-color"],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback values
|
||||
return fallBackValues;
|
||||
};
|
||||
// function to update the stored logo on local storage
|
||||
export const updateStoredLogo = (value: TEmojiLogoProps): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem("editor-calloutComponent-logo", JSON.stringify(value));
|
||||
};
|
||||
// function to get the stored background color from local storage
|
||||
export const getStoredBackgroundColor = (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background"));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
// function to update the stored background color on local storage
|
||||
export const updateStoredBackgroundColor = (value: string | null): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (value === null) {
|
||||
localStorage.removeItem("editor-calloutComponent-background");
|
||||
return;
|
||||
} else {
|
||||
localStorage.setItem("editor-calloutComponent-background", value);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { MarkType } from "@tiptap/pm/model";
|
||||
import { type EditorState, type Plugin, type Transaction, TextSelection } from "@tiptap/pm/state";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import type { CodemarkState, CursorMetaTr } from "./types";
|
||||
import { MAX_MATCH, safeResolve } from "./utils";
|
||||
|
||||
export function stepOutsideNextTrAndPass(view: EditorView, plugin: Plugin, action: "click" | "next" = "next"): boolean {
|
||||
const meta: CursorMetaTr = { action };
|
||||
view.dispatch(view.state.tr.setMeta(plugin, meta));
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onBacktick(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
if (view.state.selection.empty) return false;
|
||||
if (event.metaKey || event.shiftKey || event.altKey || event.ctrlKey) return false;
|
||||
// Create a code mark!
|
||||
const { from, to } = view.state.selection;
|
||||
if (to - from >= MAX_MATCH || view.state.doc.rangeHasMark(from, to, markType)) return false;
|
||||
const tr = view.state.tr.addMark(from, to, markType.create());
|
||||
const selected = tr.setSelection(TextSelection.create(tr.doc, to)).removeStoredMark(markType);
|
||||
view.dispatch(selected);
|
||||
return true;
|
||||
}
|
||||
|
||||
function onArrowRightInside(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
if (event.metaKey) return stepOutsideNextTrAndPass(view, plugin);
|
||||
if (event.shiftKey || event.altKey || event.ctrlKey) return false;
|
||||
const { selection, doc } = view.state;
|
||||
if (!selection.empty) return false;
|
||||
const pluginState = plugin.getState(view.state) as CodemarkState;
|
||||
const pos = selection.$from;
|
||||
const inCode = !!markType.isInSet(pos.marks());
|
||||
const nextCode = !!markType.isInSet(pos.marksAcross(safeResolve(doc, selection.from + 1)) ?? []);
|
||||
|
||||
if (pos.pos === view.state.doc.nodeSize - 3 && pos.parentOffset === pos.parent.nodeSize - 2 && pluginState?.active) {
|
||||
// Behaviour stops: `code`| at the end of the document
|
||||
view.dispatch(view.state.tr.removeStoredMark(markType));
|
||||
return true;
|
||||
}
|
||||
if (inCode === nextCode && pos.parentOffset !== 0) return false;
|
||||
if (inCode && (!pluginState?.active || pluginState.side === -1) && pos.parentOffset !== 0) {
|
||||
// `code|` --> `code`|
|
||||
view.dispatch(view.state.tr.removeStoredMark(markType));
|
||||
return true;
|
||||
}
|
||||
if (nextCode && pluginState?.side === -1) {
|
||||
// |`code` --> `|code`
|
||||
view.dispatch(view.state.tr.addStoredMark(markType.create()));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onArrowRight(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
const handled = onArrowRightInside(view, plugin, event, markType);
|
||||
if (handled) return true;
|
||||
const { selection } = view.state;
|
||||
const pos = selection.$from;
|
||||
if (selection.empty && pos.parentOffset === pos.parent.nodeSize - 2) {
|
||||
return stepOutsideNextTrAndPass(view, plugin);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onArrowLeftInside(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
if (event.metaKey) return stepOutsideNextTrAndPass(view, plugin);
|
||||
if (event.shiftKey || event.altKey || event.ctrlKey) return false;
|
||||
const { selection, doc } = view.state;
|
||||
const pluginState = plugin.getState(view.state) as CodemarkState;
|
||||
const inCode = !!markType.isInSet(selection.$from.marks());
|
||||
const nextCode = !!markType.isInSet(
|
||||
safeResolve(doc, selection.empty ? selection.from - 1 : selection.from + 1).marks() ?? []
|
||||
);
|
||||
if (inCode && pluginState?.side === -1 && selection.$from.parentOffset === 0) {
|
||||
// New line!
|
||||
// ^|`code` --> |^`code`
|
||||
return false;
|
||||
}
|
||||
if (pluginState?.side === 0 && selection.$from.parentOffset === 0) {
|
||||
// New line!
|
||||
// ^`|code` --> ^|`code`
|
||||
view.dispatch(view.state.tr.removeStoredMark(markType));
|
||||
return true;
|
||||
}
|
||||
if (inCode && nextCode && pluginState?.side === 0) {
|
||||
// `code`| --> `code|`
|
||||
view.dispatch(view.state.tr.addStoredMark(markType.create()));
|
||||
return true;
|
||||
}
|
||||
if (inCode && !nextCode && pluginState?.active && selection.$from.parentOffset === 0) {
|
||||
// ^`|code` --> ^|`code`
|
||||
view.dispatch(view.state.tr.removeStoredMark(markType));
|
||||
return true;
|
||||
}
|
||||
if (!inCode && pluginState?.active && pluginState?.side === 0) {
|
||||
// `|code` --> |`code`
|
||||
view.dispatch(view.state.tr.removeStoredMark(markType));
|
||||
return true;
|
||||
}
|
||||
if (inCode === nextCode) return false;
|
||||
if (nextCode || (!selection.empty && inCode)) {
|
||||
// `code`_|_ --> `code`| nextCode
|
||||
// `code`███ --> `code`| !selection.empty && inCode
|
||||
// `██de`___ --> `|code` !selection.empty && nextCode
|
||||
const from = selection.empty ? selection.from - 1 : selection.from;
|
||||
const selected = view.state.tr.setSelection(TextSelection.create(doc, from));
|
||||
if (!selection.empty && nextCode) {
|
||||
view.dispatch(selected.addStoredMark(markType.create()));
|
||||
} else {
|
||||
view.dispatch(selected.removeStoredMark(markType));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if ((nextCode || (!selection.empty && inCode)) && !pluginState?.active) {
|
||||
// `code`_|_ --> `code`|
|
||||
// `code`███ --> `code`|
|
||||
const from = selection.empty ? selection.from - 1 : selection.from;
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, from)).removeStoredMark(markType));
|
||||
return true;
|
||||
}
|
||||
if (inCode && !pluginState?.active && selection.$from.parentOffset > 0) {
|
||||
// `c|ode` --> `|code`
|
||||
view.dispatch(
|
||||
view.state.tr.setSelection(TextSelection.create(doc, selection.from - 1)).addStoredMark(markType.create())
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (inCode && !nextCode && pluginState?.active && pluginState.side !== -1) {
|
||||
// `x`| --> `x|` - Single character
|
||||
view.dispatch(view.state.tr.addStoredMark(markType.create()));
|
||||
return true;
|
||||
}
|
||||
if (inCode && !nextCode && pluginState?.active) {
|
||||
// `x|` --> `|x` - Single character inside
|
||||
const pos = selection.from - 1;
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, pos)).addStoredMark(markType.create()));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onArrowLeft(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
const handled = onArrowLeftInside(view, plugin, event, markType);
|
||||
if (handled) return true;
|
||||
const { selection } = view.state;
|
||||
const pos = selection.$from;
|
||||
const pluginState = plugin.getState(view.state) as CodemarkState;
|
||||
if (pos.pos === 1 && pos.parentOffset === 0 && pluginState?.side === -1) {
|
||||
return true;
|
||||
}
|
||||
if (selection.empty && pos.parentOffset === 0) {
|
||||
return stepOutsideNextTrAndPass(view, plugin);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onBackspace(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
if (event.metaKey || event.shiftKey || event.altKey || event.ctrlKey) return false;
|
||||
const { selection, doc } = view.state;
|
||||
const from = safeResolve(doc, selection.from - 1);
|
||||
const fromCode = !!markType.isInSet(from.marks());
|
||||
const startOfLine = from.parentOffset === 0;
|
||||
const toCode = !!markType.isInSet(safeResolve(doc, selection.to + 1).marks());
|
||||
if ((!fromCode || startOfLine) && !toCode) {
|
||||
// `x|` → |
|
||||
// `|████` → |
|
||||
// `|███`█ → |
|
||||
return stepOutsideNextTrAndPass(view, plugin);
|
||||
}
|
||||
// Firefox has difficulty with the decorations on -1.
|
||||
const pluginState = plugin.getState(view.state) as CodemarkState;
|
||||
if (selection.empty && pluginState?.side === -1) {
|
||||
const tr = view.state.tr.delete(selection.from - 1, selection.from);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onDelete(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
if (event.metaKey || event.shiftKey || event.altKey || event.ctrlKey) return false;
|
||||
const { selection, doc } = view.state;
|
||||
const fromCode = !!markType.isInSet(selection.$from.marks());
|
||||
const startOfLine = selection.$from.parentOffset === 0;
|
||||
const toCode = !!markType.isInSet(safeResolve(doc, selection.to + 2).marks());
|
||||
if ((!fromCode || startOfLine) && !toCode) {
|
||||
return stepOutsideNextTrAndPass(view, plugin);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function stepOutside(state: EditorState, markType: MarkType): Transaction | null {
|
||||
if (!state) return null;
|
||||
const { selection, doc } = state;
|
||||
if (!selection.empty) return null;
|
||||
const stored = !!markType.isInSet(state.storedMarks ?? []);
|
||||
const inCode = !!markType.isInSet(selection.$from.marks());
|
||||
const nextCode = !!markType.isInSet(safeResolve(doc, selection.from + 1).marks() ?? []);
|
||||
const startOfLine = selection.$from.parentOffset === 0;
|
||||
// `code|` --> `code`|
|
||||
// `|code` --> |`code`
|
||||
// ^`|code` --> ^|`code`
|
||||
if (inCode !== nextCode || (!inCode && stored !== inCode) || (inCode && startOfLine))
|
||||
return state.tr.removeStoredMark(markType);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// taken from https://github.com/curvenote/editor/tree/main/packages/prosemirror-codemark
|
||||
import { type PluginSpec, Plugin } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import type { CodemarkState, CursorMetaTr, Options } from "./types";
|
||||
import { getMarkType, codeMarkPluginKey, safeResolve } from "./utils";
|
||||
import { createInputRule } from "./input-rules";
|
||||
import {
|
||||
onArrowLeft,
|
||||
onArrowRight,
|
||||
onBackspace,
|
||||
onBacktick,
|
||||
onDelete,
|
||||
stepOutside,
|
||||
stepOutsideNextTrAndPass,
|
||||
} from "./actions";
|
||||
|
||||
function toDom(): Node {
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("fake-cursor");
|
||||
return span;
|
||||
}
|
||||
|
||||
export function getDecorationPlugin(opts?: Options) {
|
||||
const plugin: Plugin<CodemarkState> = new Plugin({
|
||||
key: codeMarkPluginKey,
|
||||
appendTransaction: (trs, oldState, newState) => {
|
||||
const prev = plugin.getState(oldState) as CodemarkState;
|
||||
const meta = trs[0]?.getMeta(plugin) as CursorMetaTr | null;
|
||||
if (prev?.next || meta?.action === "click") {
|
||||
return stepOutside(newState, getMarkType(newState, opts));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
state: {
|
||||
init: () => null,
|
||||
apply(tr, value, oldState, state): CodemarkState | null {
|
||||
const meta = tr.getMeta(plugin) as CursorMetaTr | null;
|
||||
if (meta?.action === "next") return { next: true };
|
||||
|
||||
const markType = getMarkType(state, opts);
|
||||
const nextMark = markType.isInSet(state.storedMarks ?? state.doc.resolve(tr.selection.from).marks());
|
||||
const inCode = markType.isInSet(state.doc.resolve(tr.selection.from).marks());
|
||||
const nextCode = markType.isInSet(safeResolve(state.doc, tr.selection.from + 1).marks());
|
||||
const startOfLine = tr.selection.$from.parentOffset === 0;
|
||||
if (!tr.selection.empty) return null;
|
||||
if (!nextMark && nextCode && (!inCode || startOfLine)) {
|
||||
// |`code`
|
||||
return { active: true, side: -1 };
|
||||
}
|
||||
if (nextMark && (!inCode || startOfLine)) {
|
||||
// `|code`
|
||||
return { active: true, side: 0 };
|
||||
}
|
||||
if (!nextMark && inCode && !nextCode) {
|
||||
// `code`|
|
||||
return { active: true, side: 0 };
|
||||
}
|
||||
if (nextMark && inCode && !nextCode) {
|
||||
// `code|`
|
||||
return { active: true, side: -1 };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
attributes: (state) => {
|
||||
const { active = false } = plugin.getState(state) ?? {};
|
||||
return {
|
||||
...(active ? { class: "no-cursor" } : {}),
|
||||
};
|
||||
},
|
||||
decorations: (state) => {
|
||||
const { active, side } = plugin.getState(state) ?? {};
|
||||
if (!active) return DecorationSet.empty;
|
||||
const deco = Decoration.widget(state.selection.from, toDom, { side });
|
||||
return DecorationSet.create(state.doc, [deco]);
|
||||
},
|
||||
handleKeyDown(view, event) {
|
||||
switch (event.key) {
|
||||
case "`":
|
||||
return onBacktick(view, plugin, event, getMarkType(view, opts));
|
||||
case "ArrowRight":
|
||||
return onArrowRight(view, plugin, event, getMarkType(view, opts));
|
||||
case "ArrowLeft":
|
||||
return onArrowLeft(view, plugin, event, getMarkType(view, opts));
|
||||
case "Backspace":
|
||||
return onBackspace(view, plugin, event, getMarkType(view, opts));
|
||||
case "Delete":
|
||||
return onDelete(view, plugin, event, getMarkType(view, opts));
|
||||
case "ArrowUp":
|
||||
case "ArrowDown":
|
||||
case "Home":
|
||||
case "End":
|
||||
return stepOutsideNextTrAndPass(view, plugin);
|
||||
case "e":
|
||||
case "a":
|
||||
if (!event.ctrlKey) return false;
|
||||
return stepOutsideNextTrAndPass(view, plugin);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
handleClick(view) {
|
||||
return stepOutsideNextTrAndPass(view, plugin, "click");
|
||||
},
|
||||
},
|
||||
} as PluginSpec<CodemarkState>);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
export function codemark(opts?: Options) {
|
||||
const cursorPlugin = getDecorationPlugin(opts);
|
||||
const inputRule = createInputRule(cursorPlugin, opts);
|
||||
const rules: Plugin[] = [cursorPlugin, inputRule];
|
||||
return rules;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { MarkType } from "@tiptap/pm/model";
|
||||
import { type PluginSpec, type Transaction, Plugin, TextSelection } from "@tiptap/pm/state";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import type { Options } from "./types";
|
||||
import { getMarkType, MAX_MATCH } from "./utils";
|
||||
|
||||
type InputRuleState = {
|
||||
transform: Transaction;
|
||||
from: number;
|
||||
to: number;
|
||||
text: string;
|
||||
} | null;
|
||||
|
||||
type Plugins = { input: Plugin; cursor: Plugin };
|
||||
|
||||
type Handler = (
|
||||
markType: MarkType,
|
||||
state: EditorView,
|
||||
text: string,
|
||||
match: RegExpExecArray,
|
||||
from: number,
|
||||
to: number,
|
||||
plugin: Plugins
|
||||
) => boolean;
|
||||
|
||||
type Rule = {
|
||||
match: RegExp;
|
||||
handler: Handler;
|
||||
};
|
||||
|
||||
function stopMatch(markType: MarkType, view: EditorView, from: number, to: number): boolean {
|
||||
const stored = markType.isInSet(view.state.storedMarks ?? view.state.doc.resolve(from).marks());
|
||||
const range = view.state.doc.rangeHasMark(from, to, markType);
|
||||
// Don't create it if there is code in between!
|
||||
if (stored || range) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const markBefore: Rule = {
|
||||
match: /`((?:[^`\w]|[\w])+)`$/,
|
||||
handler: (markType, view, text, match, from, to, plugins) => {
|
||||
if (stopMatch(markType, view, from, to)) return false;
|
||||
const code = match[1];
|
||||
const mark = markType.create();
|
||||
const pos = from + code.length;
|
||||
const tr = view.state.tr.delete(from, to).insertText(code).addMark(from, pos, mark);
|
||||
const selected = tr.setSelection(TextSelection.create(tr.doc, pos)).removeStoredMark(markType);
|
||||
const withMeta = selected.setMeta(plugins.input, {
|
||||
transform: selected,
|
||||
from,
|
||||
to,
|
||||
text: `\`${code}${text}`,
|
||||
});
|
||||
view.dispatch(withMeta);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
const markAfter: Rule = {
|
||||
match: /^`((?:[^`\w]|[\w])+)`/,
|
||||
handler: (markType, view, text, match, from, to, plugins) => {
|
||||
if (stopMatch(markType, view, from, to)) return false;
|
||||
const mark = markType.create();
|
||||
const code = match[1];
|
||||
const pos = from;
|
||||
const tr = view.state.tr
|
||||
.delete(from, to)
|
||||
.insertText(code)
|
||||
.addMark(from, from + code.length, mark);
|
||||
const selected = tr.setSelection(TextSelection.create(tr.doc, pos)).addStoredMark(markType.create());
|
||||
const withMeta = selected.setMeta(plugins.input, {
|
||||
transform: selected,
|
||||
from,
|
||||
to,
|
||||
text: `\`${code}${text}`,
|
||||
});
|
||||
view.dispatch(withMeta);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function run(markType: MarkType, view: EditorView, from: number, to: number, text: string, plugins: Plugins) {
|
||||
if (view.composing) return false;
|
||||
const { state } = view;
|
||||
const $from = state.doc.resolve(from);
|
||||
if ($from.parent.type.spec.code) return false;
|
||||
|
||||
const leafText = "\ufffc";
|
||||
const textBefore =
|
||||
$from.parent.textBetween(Math.max(0, $from.parentOffset - MAX_MATCH), $from.parentOffset, undefined, leafText) +
|
||||
text;
|
||||
const textAfter =
|
||||
text +
|
||||
$from.parent.textBetween(
|
||||
$from.parentOffset,
|
||||
Math.min($from.parent.nodeSize - 2, $from.parentOffset + MAX_MATCH),
|
||||
undefined,
|
||||
leafText
|
||||
);
|
||||
const matchB = markBefore.match.exec(textBefore);
|
||||
const matchA = markAfter.match.exec(textAfter);
|
||||
if (matchB) {
|
||||
const handled = markBefore.handler(
|
||||
markType,
|
||||
view,
|
||||
text,
|
||||
matchB,
|
||||
from - matchB[0].length + text.length,
|
||||
to,
|
||||
plugins
|
||||
);
|
||||
if (handled) return handled;
|
||||
}
|
||||
if (matchA)
|
||||
return markAfter.handler(markType, view, text, matchA, from, to + matchA[0].length - text.length, plugins);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createInputRule(cursorPlugin: Plugin, opts?: Options) {
|
||||
const plugin: Plugin<InputRuleState> = new Plugin({
|
||||
isInputRules: true,
|
||||
state: {
|
||||
init: () => null,
|
||||
apply(tr, prev) {
|
||||
const meta = tr.getMeta(plugin);
|
||||
if (meta) return meta;
|
||||
return tr.selectionSet || tr.docChanged ? null : prev;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
handleTextInput(view, from, to, text) {
|
||||
const markType = getMarkType(view, opts);
|
||||
return run(markType, view, from, to, text, { input: plugin, cursor: cursorPlugin });
|
||||
},
|
||||
},
|
||||
} as PluginSpec<InputRuleState>);
|
||||
return plugin;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MarkType } from "prosemirror-model";
|
||||
|
||||
export type Options = {
|
||||
markType?: MarkType;
|
||||
};
|
||||
|
||||
export type CodemarkState = {
|
||||
active?: boolean;
|
||||
side?: -1 | 0;
|
||||
next?: true; // Move outside of code after next transaction
|
||||
click?: true; // When the editor is clicked on
|
||||
} | null;
|
||||
|
||||
export type CursorMetaTr = { action: "next" } | { action: "click" };
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { MarkType, Node } from "@tiptap/pm/model";
|
||||
import { type EditorState, PluginKey } from "@tiptap/pm/state";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import type { Options } from "./types";
|
||||
|
||||
export const MAX_MATCH = 100;
|
||||
export const codeMarkPluginKey = new PluginKey("codemark");
|
||||
|
||||
export function getMarkType(view: EditorView | EditorState, opts?: Options): MarkType {
|
||||
if ("schema" in view) return opts?.markType ?? view.schema.marks.code;
|
||||
return opts?.markType ?? view.state.schema.marks.code;
|
||||
}
|
||||
|
||||
export function safeResolve(doc: Node, pos: number) {
|
||||
return doc.resolve(Math.min(Math.max(1, pos), doc.nodeSize - 2));
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
@@ -18,10 +17,10 @@ import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
import { CustomTextAlignExtension } from "./text-align";
|
||||
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
|
||||
import { CustomColorExtension } from "./custom-color";
|
||||
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
|
||||
|
||||
export const CoreEditorExtensionsWithoutProps: Extensions = [
|
||||
export const CoreEditorExtensionsWithoutProps = [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
@@ -88,8 +87,8 @@ export const CoreEditorExtensionsWithoutProps: Extensions = [
|
||||
TableRow,
|
||||
CustomMentionWithoutProps(),
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutExtensionConfig,
|
||||
CustomColorExtension,
|
||||
...CoreEditorAdditionalExtensionsWithoutProps,
|
||||
];
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import codemark from "prosemirror-codemark";
|
||||
import { codemark } from "@/extensions/code-mark";
|
||||
|
||||
export const CustomCodeMarkPlugin = Extension.create({
|
||||
name: "codemarkPlugin",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
@@ -9,6 +8,7 @@ import StarterKit from "@tiptap/starter-kit";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
// extensions
|
||||
import {
|
||||
CustomCalloutExtension,
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
CustomCodeMarkPlugin,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DropHandlerExtension,
|
||||
ImageExtension,
|
||||
ListKeymap,
|
||||
SmoothCursorExtension,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
@@ -33,11 +34,10 @@ import {
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
|
||||
type TArguments = {
|
||||
enableHistory: boolean;
|
||||
has_enabled_smooth_cursor: boolean;
|
||||
fileHandler: TFileHandler;
|
||||
mentionConfig: {
|
||||
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
@@ -47,8 +47,8 @@ type TArguments = {
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
|
||||
export const CoreEditorExtensions = (args: TArguments) => {
|
||||
const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex, has_enabled_smooth_cursor } = args;
|
||||
|
||||
return [
|
||||
StarterKit.configure({
|
||||
@@ -162,7 +162,8 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
}),
|
||||
CharacterCount,
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutExtension,
|
||||
CustomColorExtension,
|
||||
...CoreEditorAdditionalExtensions(),
|
||||
has_enabled_smooth_cursor ? SmoothCursorExtension : null,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./callout";
|
||||
export * from "./code";
|
||||
export * from "./code-inline";
|
||||
export * from "./custom-image";
|
||||
@@ -22,3 +23,5 @@ export * from "./quote";
|
||||
export * from "./read-only-extensions";
|
||||
export * from "./side-menu";
|
||||
export * from "./text-align";
|
||||
export * from "./smooth-cursor";
|
||||
export * from "./code-mark";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
@@ -23,14 +22,13 @@ import {
|
||||
HeadingListExtension,
|
||||
CustomReadOnlyImageExtension,
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutReadOnlyExtension,
|
||||
CustomColorExtension,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { IMentionHighlight, TFileHandler } from "@/types";
|
||||
// plane editor extensions
|
||||
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
|
||||
type Props = {
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
@@ -39,7 +37,7 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
export const CoreReadOnlyEditorExtensions = (props: Props) => {
|
||||
const { fileHandler, mentionConfig } = props;
|
||||
|
||||
return [
|
||||
@@ -129,6 +127,6 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
CustomColorExtension,
|
||||
HeadingListExtension,
|
||||
CustomTextAlignExtension,
|
||||
...CoreReadOnlyEditorAdditionalExtensions(),
|
||||
CustomCalloutReadOnlyExtension,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
MessageSquareText,
|
||||
MinusSquare,
|
||||
Table,
|
||||
TextQuote,
|
||||
@@ -34,20 +35,20 @@ import {
|
||||
toggleTextColor,
|
||||
toggleBackgroundColor,
|
||||
insertImage,
|
||||
insertCallout,
|
||||
setText,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types";
|
||||
import { TSlashCommandAdditionalOption } from "./root";
|
||||
import { CommandProps, ISlashCommandItem } from "@/types";
|
||||
|
||||
export type TSlashCommandSection = {
|
||||
key: TSlashCommandSectionKeys;
|
||||
key: string;
|
||||
title?: string;
|
||||
items: ISlashCommandItem[];
|
||||
};
|
||||
|
||||
export const getSlashCommandFilteredSections =
|
||||
(additionalOptions?: TSlashCommandAdditionalOption[]) =>
|
||||
(additionalOptions?: ISlashCommandItem[]) =>
|
||||
({ query }: { query: string }): TSlashCommandSection[] => {
|
||||
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
|
||||
{
|
||||
@@ -179,6 +180,15 @@ export const getSlashCommandFilteredSections =
|
||||
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
||||
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
|
||||
},
|
||||
{
|
||||
commandKey: "callout",
|
||||
key: "callout",
|
||||
title: "Callout",
|
||||
icon: <MessageSquareText className="size-3.5" />,
|
||||
description: "Insert callout",
|
||||
searchTerms: ["callout", "comment", "message", "info", "alert"],
|
||||
command: ({ editor, range }: CommandProps) => insertCallout(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "divider",
|
||||
key: "divider",
|
||||
@@ -191,7 +201,7 @@ export const getSlashCommandFilteredSections =
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "text-colors",
|
||||
key: "text-color",
|
||||
title: "Colors",
|
||||
items: [
|
||||
{
|
||||
@@ -232,7 +242,7 @@ export const getSlashCommandFilteredSections =
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "background-colors",
|
||||
key: "background-color",
|
||||
title: "Background colors",
|
||||
items: [
|
||||
{
|
||||
@@ -269,18 +279,8 @@ export const getSlashCommandFilteredSections =
|
||||
},
|
||||
];
|
||||
|
||||
additionalOptions?.forEach((item) => {
|
||||
const sectionToPushTo = SLASH_COMMAND_SECTIONS.find((s) => s.key === item.section) ?? SLASH_COMMAND_SECTIONS[0];
|
||||
const itemIndexToPushAfter = sectionToPushTo.items.findIndex((i) => i.commandKey === item.pushAfter);
|
||||
if (itemIndexToPushAfter !== undefined) {
|
||||
const resolvedIndex =
|
||||
itemIndexToPushAfter + 1 < sectionToPushTo.items.length
|
||||
? itemIndexToPushAfter + 1
|
||||
: sectionToPushTo.items.length - 1;
|
||||
sectionToPushTo.items.splice(resolvedIndex, 0, item);
|
||||
} else {
|
||||
sectionToPushTo.items.push(item);
|
||||
}
|
||||
additionalOptions?.map((item) => {
|
||||
SLASH_COMMAND_SECTIONS?.[0]?.items.push(item);
|
||||
});
|
||||
|
||||
const filteredSlashSections = SLASH_COMMAND_SECTIONS.map((section) => ({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
// types
|
||||
import { ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types";
|
||||
import { ISlashCommandItem } from "@/types";
|
||||
// components
|
||||
import { getSlashCommandFilteredSections } from "./command-items-list";
|
||||
import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu";
|
||||
@@ -12,11 +12,6 @@ export type SlashCommandOptions = {
|
||||
suggestion: Omit<SuggestionOptions, "editor">;
|
||||
};
|
||||
|
||||
export type TSlashCommandAdditionalOption = ISlashCommandItem & {
|
||||
section: TSlashCommandSectionKeys;
|
||||
pushAfter: TEditorCommands;
|
||||
};
|
||||
|
||||
const Command = Extension.create<SlashCommandOptions>({
|
||||
name: "slash-command",
|
||||
addOptions() {
|
||||
@@ -107,7 +102,7 @@ const renderItems = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const SlashCommands = (additionalOptions?: TSlashCommandAdditionalOption[]) =>
|
||||
export const SlashCommands = (additionalOptions?: ISlashCommandItem[]) =>
|
||||
Command.configure({
|
||||
suggestion: {
|
||||
items: getSlashCommandFilteredSections(additionalOptions),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { smoothCursorPlugin } from "./plugin";
|
||||
|
||||
export const SmoothCursorExtension = Extension.create({
|
||||
name: "smoothCursorExtension",
|
||||
addProseMirrorPlugins() {
|
||||
return [smoothCursorPlugin()];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { type Selection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { type EditorView, Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { codeMarkPluginKey } from "@/extensions/code-mark/utils";
|
||||
|
||||
export const PROSEMIRROR_SMOOTH_CURSOR_CLASS = "prosemirror-smooth-cursor";
|
||||
const BLINK_DELAY = 750;
|
||||
|
||||
export function smoothCursorPlugin(): Plugin {
|
||||
let smoothCursor: HTMLElement | null = typeof document === "undefined" ? null : document.createElement("div");
|
||||
let rafId: number | undefined;
|
||||
let blinkTimeoutId: number | undefined;
|
||||
let isEditorFocused = false;
|
||||
let lastCursorPosition = { x: 0, y: 0 };
|
||||
|
||||
function isCodemarkCursorActive(view: EditorView) {
|
||||
const codemarkState = codeMarkPluginKey.getState(view.state);
|
||||
return codemarkState?.active === true;
|
||||
}
|
||||
|
||||
function updateCursor(view?: EditorView, cursor?: HTMLElement) {
|
||||
if (!view || !view.dom || view.isDestroyed || !cursor) return;
|
||||
|
||||
if (!isEditorFocused || isCodemarkCursorActive(view)) {
|
||||
cursor.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
cursor.style.display = "block";
|
||||
|
||||
const { state, dom } = view;
|
||||
const { selection } = state;
|
||||
if (!isTextSelection(selection)) return;
|
||||
|
||||
const cursorRect = getCursorRect(view, selection.$head === selection.$from);
|
||||
|
||||
if (!cursorRect) return cursor;
|
||||
|
||||
const editorRect = dom.getBoundingClientRect();
|
||||
|
||||
const className = PROSEMIRROR_SMOOTH_CURSOR_CLASS;
|
||||
|
||||
// Calculate the exact position
|
||||
const x = cursorRect.left - editorRect.left;
|
||||
const y = cursorRect.top - editorRect.top;
|
||||
|
||||
// Check if cursor position has changed
|
||||
if (x !== lastCursorPosition.x || y !== lastCursorPosition.y) {
|
||||
lastCursorPosition = { x, y };
|
||||
cursor.classList.remove(`${className}--blinking`);
|
||||
|
||||
// Clear existing timeout
|
||||
if (blinkTimeoutId) {
|
||||
window.clearTimeout(blinkTimeoutId);
|
||||
}
|
||||
|
||||
// Set new timeout for blinking
|
||||
blinkTimeoutId = window.setTimeout(() => {
|
||||
if (cursor && isEditorFocused) {
|
||||
cursor.classList.add(`${className}--blinking`);
|
||||
}
|
||||
}, BLINK_DELAY);
|
||||
}
|
||||
|
||||
cursor.className = className;
|
||||
cursor.style.height = `${cursorRect.bottom - cursorRect.top}px`;
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
cursor.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
||||
});
|
||||
}
|
||||
|
||||
return new Plugin({
|
||||
key,
|
||||
view: (view) => {
|
||||
const doc = view.dom.ownerDocument;
|
||||
smoothCursor = smoothCursor || document.createElement("div");
|
||||
const cursor = smoothCursor;
|
||||
|
||||
const update = () => {
|
||||
if (rafId !== undefined) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
updateCursor(view, cursor);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
isEditorFocused = true;
|
||||
update();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
isEditorFocused = false;
|
||||
if (blinkTimeoutId) {
|
||||
window.clearTimeout(blinkTimeoutId);
|
||||
}
|
||||
cursor.classList.remove(`${PROSEMIRROR_SMOOTH_CURSOR_CLASS}--blinking`);
|
||||
update();
|
||||
};
|
||||
|
||||
let observer: ResizeObserver | undefined;
|
||||
if (window.ResizeObserver) {
|
||||
observer = new window.ResizeObserver(update);
|
||||
observer?.observe(view.dom);
|
||||
}
|
||||
|
||||
doc.addEventListener("selectionchange", update);
|
||||
view.dom.addEventListener("focus", handleFocus);
|
||||
view.dom.addEventListener("blur", handleBlur);
|
||||
|
||||
return {
|
||||
update,
|
||||
destroy: () => {
|
||||
doc.removeEventListener("selectionchange", update);
|
||||
view.dom.removeEventListener("focus", handleFocus);
|
||||
view.dom.removeEventListener("blur", handleBlur);
|
||||
observer?.unobserve(view.dom);
|
||||
if (rafId !== undefined) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
if (blinkTimeoutId) {
|
||||
window.clearTimeout(blinkTimeoutId);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
if (!smoothCursor || !isTextSelection(state.selection) || !state.selection.empty) return;
|
||||
|
||||
return DecorationSet.create(state.doc, [
|
||||
Decoration.widget(0, smoothCursor, {
|
||||
key: PROSEMIRROR_SMOOTH_CURSOR_CLASS,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
|
||||
attributes: () => ({
|
||||
class: isEditorFocused ? "smooth-cursor-enabled" : "",
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const key = new PluginKey(PROSEMIRROR_SMOOTH_CURSOR_CLASS);
|
||||
|
||||
function getCursorRect(
|
||||
view: EditorView,
|
||||
toStart: boolean
|
||||
): { left: number; right: number; top: number; bottom: number } | null {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || !selection.rangeCount) return null;
|
||||
|
||||
const range = selection?.getRangeAt(0)?.cloneRange();
|
||||
if (!range) return null;
|
||||
|
||||
range.collapse(toStart);
|
||||
const rects = range.getClientRects();
|
||||
const rect = rects?.length ? rects[rects.length - 1] : null;
|
||||
if (rect?.height) return rect;
|
||||
|
||||
return view.coordsAtPos(view.state.selection.head);
|
||||
}
|
||||
|
||||
function isTextSelection(selection: Selection): selection is TextSelection {
|
||||
return selection && typeof selection === "object" && "$cursor" in selection;
|
||||
}
|
||||
@@ -189,3 +189,7 @@ export const insertHorizontalRule = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||
else editor.chain().focus().setHorizontalRule().run();
|
||||
};
|
||||
export const insertCallout = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).insertCallout().run();
|
||||
else editor.chain().focus().insertCallout().run();
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TCollaborativeEditorProps } from "@/types";
|
||||
export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||
const {
|
||||
onTransaction,
|
||||
has_enabled_smooth_cursor,
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
@@ -99,6 +100,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||
],
|
||||
fileHandler,
|
||||
handleEditorReady,
|
||||
has_enabled_smooth_cursor,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface CustomEditorProps {
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: MutableRefObject<EditorRefApi | null>;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
has_enabled_smooth_cursor?: boolean;
|
||||
id?: string;
|
||||
initialValue?: string;
|
||||
mentionHandler: {
|
||||
@@ -62,6 +63,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
tabIndex,
|
||||
value,
|
||||
autofocus = false,
|
||||
has_enabled_smooth_cursor = true,
|
||||
} = props;
|
||||
// states
|
||||
|
||||
@@ -79,6 +81,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
},
|
||||
extensions: [
|
||||
...CoreEditorExtensions({
|
||||
has_enabled_smooth_cursor,
|
||||
enableHistory,
|
||||
fileHandler,
|
||||
mentionConfig: {
|
||||
|
||||
@@ -36,6 +36,7 @@ type TCollaborativeEditorHookProps = {
|
||||
};
|
||||
|
||||
export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
has_enabled_smooth_cursor: boolean;
|
||||
onTransaction?: () => void;
|
||||
embedHandler?: TEmbedConfig;
|
||||
fileHandler: TFileHandler;
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
TServerHandler,
|
||||
} from "@/types";
|
||||
import { TTextAlign } from "@/extensions";
|
||||
// plane editor types
|
||||
import { TEditorAdditionalCommands } from "@/plane-editor/types";
|
||||
|
||||
export type TEditorCommands =
|
||||
| "text"
|
||||
@@ -41,7 +39,7 @@ export type TEditorCommands =
|
||||
| "text-color"
|
||||
| "background-color"
|
||||
| "text-align"
|
||||
| TEditorAdditionalCommands;
|
||||
| "callout";
|
||||
|
||||
export type TCommandExtraProps = {
|
||||
image: {
|
||||
@@ -119,6 +117,7 @@ export interface IEditorProps {
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onTransaction?: () => void;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
has_enabled_smooth_cursor?: boolean;
|
||||
autofocus?: boolean;
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
@@ -143,6 +142,7 @@ export interface ICollaborativeDocumentEditor
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
serverHandler?: TServerHandler;
|
||||
user: TUserDetails;
|
||||
has_enabled_smooth_cursor?: boolean;
|
||||
}
|
||||
|
||||
// read only editor props
|
||||
|
||||
@@ -8,8 +8,6 @@ export type CommandProps = {
|
||||
range: Range;
|
||||
};
|
||||
|
||||
export type TSlashCommandSectionKeys = "general" | "text-colors" | "background-colors";
|
||||
|
||||
export type ISlashCommandItem = {
|
||||
commandKey: TEditorCommands;
|
||||
key: string;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
:root {
|
||||
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
@@ -474,4 +478,34 @@ p + p {
|
||||
[data-background-color="purple"] {
|
||||
background-color: var(--editor-colors-purple-background);
|
||||
}
|
||||
/* end background colors */
|
||||
|
||||
.smooth-cursor-enabled {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.prosemirror-smooth-cursor {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
background-color: currentColor;
|
||||
pointer-events: none;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transition: transform 0.2s var(--ease-out-quart);
|
||||
will-change: transform;
|
||||
opacity: 0.8;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.prosemirror-smooth-cursor--blinking {
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
from,
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -62,6 +62,7 @@ export type TUserProfile = {
|
||||
billing_address_country: string | undefined;
|
||||
billing_address: string | undefined;
|
||||
has_billing_address: boolean;
|
||||
has_enabled_smooth_cursor: boolean;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
};
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { IUserTheme } from "@plane/types";
|
||||
import { setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { CustomThemeSelector, ThemeSwitch, PageHead } from "@/components/core";
|
||||
import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile";
|
||||
// constants
|
||||
import { I_THEME_OPTION, THEME_OPTIONS } from "@/constants/themes";
|
||||
// helpers
|
||||
import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store";
|
||||
|
||||
const ProfileAppearancePage = observer(() => {
|
||||
const { setTheme } = useTheme();
|
||||
// states
|
||||
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
|
||||
// hooks
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfile?.theme?.theme) {
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
|
||||
if (userThemeOption) {
|
||||
setCurrentTheme(userThemeOption);
|
||||
}
|
||||
}
|
||||
}, [userProfile?.theme?.theme]);
|
||||
|
||||
const handleThemeChange = (themeOption: I_THEME_OPTION) => {
|
||||
applyThemeChange({ theme: themeOption.value });
|
||||
|
||||
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
|
||||
setPromiseToast(updateCurrentUserThemePromise, {
|
||||
loading: "Updating theme...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Theme updated successfully!",
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => "Failed to Update the theme",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const applyThemeChange = (theme: Partial<IUserTheme>) => {
|
||||
setTheme(theme?.theme || "system");
|
||||
|
||||
if (theme?.theme === "custom" && theme?.palette) {
|
||||
applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
|
||||
} else unsetCustomCssVariables();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Appearance" />
|
||||
{userProfile ? (
|
||||
<ProfileSettingContentWrapper>
|
||||
<ProfileSettingContentHeader title="Appearance" />
|
||||
<div className="grid grid-cols-12 gap-4 py-6 sm:gap-16">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-lg font-semibold text-custom-text-100">Theme</h4>
|
||||
<p className="text-sm text-custom-text-200">Select or customize your interface color scheme.</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
|
||||
</div>
|
||||
</div>
|
||||
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
|
||||
</ProfileSettingContentWrapper>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileAppearancePage;
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { PreferencesList } from "@/components/preferences/list";
|
||||
import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store";
|
||||
|
||||
const ProfileAppearancePage = observer(() => {
|
||||
// hooks
|
||||
const { data: userProfile } = useUserProfile();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Appearance" />
|
||||
{userProfile ? (
|
||||
<ProfileSettingContentWrapper>
|
||||
<ProfileSettingContentHeader title="Preferences" />
|
||||
<PreferencesList />
|
||||
</ProfileSettingContentWrapper>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileAppearancePage;
|
||||
@@ -19,7 +19,7 @@ export const ThemeSwitch: FC<Props> = (props) => {
|
||||
value={value}
|
||||
label={
|
||||
value ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div
|
||||
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
|
||||
style={{
|
||||
|
||||
@@ -12,7 +12,7 @@ import { cn } from "@/helpers/common.helper";
|
||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
import { isCommentEmpty } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useMember, useMention, useUser } from "@/hooks/store";
|
||||
import { useMember, useMention, useUser, useUserProfile } from "@/hooks/store";
|
||||
// plane web hooks
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
|
||||
@@ -45,6 +45,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||
} = props;
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
data: { has_enabled_smooth_cursor },
|
||||
} = useUserProfile();
|
||||
const {
|
||||
getUserDetails,
|
||||
project: { getProjectMemberIds },
|
||||
@@ -79,6 +82,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||
workspaceId,
|
||||
workspaceSlug,
|
||||
})}
|
||||
has_enabled_smooth_cursor={has_enabled_smooth_cursor}
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
suggestions: mentionSuggestions,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { IUserLite } from "@plane/types";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
// hooks
|
||||
import { useMember, useMention, useUser } from "@/hooks/store";
|
||||
import { useMember, useMention, useUser, useUserProfile } from "@/hooks/store";
|
||||
// plane web hooks
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
|
||||
@@ -22,6 +22,9 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
||||
const { containerClassName, workspaceSlug, workspaceId, projectId, uploadFile, ...rest } = props;
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
data: { has_enabled_smooth_cursor },
|
||||
} = useUserProfile();
|
||||
const {
|
||||
getUserDetails,
|
||||
project: { getProjectMemberIds },
|
||||
@@ -49,6 +52,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
||||
workspaceId,
|
||||
workspaceSlug,
|
||||
})}
|
||||
has_enabled_smooth_cursor={has_enabled_smooth_cursor}
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
suggestions: mentionSuggestions,
|
||||
|
||||
@@ -29,6 +29,11 @@ export const IssueRelationActivity: FC<TIssueRelationActivity> = observer((props
|
||||
ends={ends}
|
||||
>
|
||||
{activityContent}
|
||||
{activity.old_value === "" ? (
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}.</span>
|
||||
) : (
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}.</span>
|
||||
)}
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ import { cn, LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper";
|
||||
import { getEditorFileHandlers, getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
import { generateRandomColor } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
|
||||
import { useMember, useMention, useUser, useUserProfile, useWorkspace } from "@/hooks/store";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
// plane web components
|
||||
import { EditorAIMenu } from "@/plane-web/components/pages";
|
||||
@@ -64,6 +64,9 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
data: { has_enabled_smooth_cursor },
|
||||
} = useUserProfile();
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
const {
|
||||
getUserDetails,
|
||||
@@ -190,6 +193,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
{isContentEditable ? (
|
||||
<CollaborativeDocumentEditorWithRef
|
||||
has_enabled_smooth_cursor={has_enabled_smooth_cursor}
|
||||
id={pageId}
|
||||
fileHandler={getEditorFileHandlers({
|
||||
maxFileSize,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { SmoothCursorToggle } from "./smooth-cursor-toggle";
|
||||
import { ThemeSwitcher } from "./theme-switcher";
|
||||
|
||||
export interface PreferenceOption {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
component: React.ComponentType<{ option: PreferenceOption }>;
|
||||
}
|
||||
|
||||
export const PREFERENCE_OPTIONS: PreferenceOption[] = [
|
||||
{
|
||||
id: "theme",
|
||||
title: "Theme",
|
||||
description: "Select or customize your interface color scheme.",
|
||||
component: ThemeSwitcher,
|
||||
},
|
||||
{
|
||||
id: "smoothCursor",
|
||||
title: "Smooth cursor movement",
|
||||
description: "Select the cursor motion style that feels right for you",
|
||||
component: SmoothCursorToggle,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./theme-switcher";
|
||||
export * from "./section";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PREFERENCE_OPTIONS } from "./config";
|
||||
import { PreferencesSection } from "./section";
|
||||
|
||||
export const PreferencesList = observer(() => {
|
||||
console.log("list");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{PREFERENCE_OPTIONS.map((option) => {
|
||||
const Component = option.component;
|
||||
return <Component key={option.id} option={option} />;
|
||||
return (
|
||||
<PreferencesSection
|
||||
key={option.id}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
control={<Component />}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
interface SettingsSectionProps {
|
||||
title: string;
|
||||
description: string;
|
||||
control: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PreferencesSection = ({ title, description, control }: SettingsSectionProps) => (
|
||||
<div className="grid grid-cols-12 gap-4 py-6 sm:gap-16">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-lg font-semibold text-custom-text-100">{title}</h4>
|
||||
<p className="text-sm text-custom-text-200">{description}</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6">{control}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { useUserProfile } from "@/hooks/store";
|
||||
import { PreferenceOption } from "./config";
|
||||
import { PreferencesSection } from "./section";
|
||||
|
||||
export const SmoothCursorToggle = observer((props: { option: PreferenceOption }) => {
|
||||
const {
|
||||
data: { has_enabled_smooth_cursor },
|
||||
updateUserProfile,
|
||||
} = useUserProfile();
|
||||
return (
|
||||
<PreferencesSection
|
||||
title={props.option.title}
|
||||
description={props.option.description}
|
||||
control={
|
||||
<div className="flex items-center justify-start sm:justify-end">
|
||||
<ToggleSwitch
|
||||
value={has_enabled_smooth_cursor}
|
||||
onChange={(value) => {
|
||||
updateUserProfile({ has_enabled_smooth_cursor: value });
|
||||
}}
|
||||
label={"smooth-cursor-toggle"}
|
||||
size={"sm"}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { IUserTheme } from "@plane/types";
|
||||
import { setPromiseToast } from "@plane/ui";
|
||||
|
||||
// components
|
||||
import { CustomThemeSelector, ThemeSwitch } from "@/components/core";
|
||||
// constants
|
||||
import { I_THEME_OPTION, THEME_OPTIONS } from "@/constants/themes";
|
||||
// helpers
|
||||
import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
|
||||
// hooks
|
||||
import { useUserProfile } from "@/hooks/store";
|
||||
import { PreferenceOption } from "./config";
|
||||
import { PreferencesSection } from ".";
|
||||
|
||||
export const ThemeSwitcher = observer((props: { option: PreferenceOption }) => {
|
||||
// hooks
|
||||
const { setTheme } = useTheme();
|
||||
const { data: userProfile, updateUserTheme } = useUserProfile();
|
||||
|
||||
// states
|
||||
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
|
||||
|
||||
// initialize theme
|
||||
useEffect(() => {
|
||||
if (!userProfile?.theme?.theme) return;
|
||||
|
||||
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile.theme.theme);
|
||||
|
||||
if (userThemeOption) {
|
||||
setCurrentTheme(userThemeOption);
|
||||
}
|
||||
}, [userProfile?.theme?.theme]);
|
||||
|
||||
// handlers
|
||||
const applyThemeChange = useCallback(
|
||||
(theme: Partial<IUserTheme>) => {
|
||||
const themeValue = theme?.theme || "system";
|
||||
setTheme(themeValue);
|
||||
|
||||
if (theme?.theme === "custom" && theme?.palette) {
|
||||
const defaultPalette = "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5";
|
||||
const palette = theme.palette !== ",,,," ? theme.palette : defaultPalette;
|
||||
applyTheme(palette, false);
|
||||
} else {
|
||||
unsetCustomCssVariables();
|
||||
}
|
||||
},
|
||||
[setTheme]
|
||||
);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
async (themeOption: I_THEME_OPTION) => {
|
||||
try {
|
||||
applyThemeChange({ theme: 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);
|
||||
}
|
||||
},
|
||||
[applyThemeChange, updateUserTheme]
|
||||
);
|
||||
|
||||
if (!userProfile) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PreferencesSection
|
||||
title={props.option.title}
|
||||
description={props.option.description}
|
||||
control={
|
||||
<div className="w-[352px]">
|
||||
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{userProfile.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -31,10 +31,10 @@ export const PROFILE_ACTION_LINKS: {
|
||||
Icon: Activity,
|
||||
},
|
||||
{
|
||||
key: "appearance",
|
||||
label: "Appearance",
|
||||
href: `/profile/appearance`,
|
||||
highlight: (pathname: string) => pathname.includes("/profile/appearance"),
|
||||
key: "preferences",
|
||||
label: "Preferences",
|
||||
href: `/profile/preferences`,
|
||||
highlight: (pathname: string) => pathname.includes("/profile/preferences/"),
|
||||
Icon: Settings2,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -50,6 +50,7 @@ export class ProfileStore implements IUserProfileStore {
|
||||
workspace_create: false,
|
||||
workspace_invite: false,
|
||||
},
|
||||
has_enabled_smooth_cursor: true,
|
||||
is_onboarded: false,
|
||||
is_tour_completed: false,
|
||||
use_case: undefined,
|
||||
@@ -101,7 +102,8 @@ export class ProfileStore implements IUserProfileStore {
|
||||
const userProfile = await this.userService.getCurrentUserProfile();
|
||||
runInAction(() => {
|
||||
this.isLoading = false;
|
||||
this.data = userProfile;
|
||||
this.mutateUserProfile(userProfile);
|
||||
// this.data = userProfile;
|
||||
});
|
||||
return userProfile;
|
||||
} catch (error) {
|
||||
@@ -127,7 +129,7 @@ export class ProfileStore implements IUserProfileStore {
|
||||
if (currentUserProfileData) {
|
||||
this.mutateUserProfile(data);
|
||||
}
|
||||
const userProfile = await this.userService.updateCurrentUserProfile(data);
|
||||
// const userProfile = await this.userService.updateCurrentUserProfile(data);
|
||||
return userProfile;
|
||||
} catch {
|
||||
if (currentUserProfileData) {
|
||||
@@ -169,7 +171,6 @@ export class ProfileStore implements IUserProfileStore {
|
||||
runInAction(() => {
|
||||
this.mutateUserProfile({ ...dataToUpdate, is_onboarded: true });
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.error = {
|
||||
@@ -179,7 +180,7 @@ export class ProfileStore implements IUserProfileStore {
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description updates the user tour completed status
|
||||
|
||||
Reference in New Issue
Block a user