Compare commits

...
Author SHA1 Message Date
Palanikannan M 0f10dbf6b8 Merge branch 'preview' into fix/realtime-sync 2025-12-01 13:52:31 +05:30
Palanikannan M 947cb7040d Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-12-01 13:52:08 +05:30
Vipin ChaudharyandGitHub 8db95d9ec0 [WIKI-823] fix: callout element emoji search (#8201)
* fix: emoji search input

* fix: enhance keyboard navigation in EmojiPicker component
2025-12-01 13:37:20 +05:30
b-saikrishnakanthandGitHub 27bf2575bd [WEB-5534] fix: intake state dropdown (#8199) 2025-12-01 12:50:01 +05:30
b-saikrishnakanthandGitHub 22bb3c5ecc [WEB-5490] fix: intake section filter persists incorrectly across projects (#8187) 2025-12-01 12:49:05 +05:30
Aaryan KhandelwalandGitHub 6b85d67f6c [WIKI-788] regression: copy markdown option #8200 2025-12-01 12:48:28 +05:30
Prateek ShouryaandGitHub 123f59e74b [WEB-5525] improvement: update WorkspaceMenuRoot to use variant prop for layout adjustments (#8196) 2025-11-28 19:00:25 +05:30
sriram veeraghantaandGitHub c7bf912cf2 fix: state group choices (#8198) 2025-11-28 18:06:00 +05:30
Aaryan KhandelwalandGitHub 2980836015 [WEB-5527] fix: extended sidebar (#8197) 2025-11-28 16:54:53 +05:30
78fbdde165 [WEB-5282] chore: triage state in intake (#8135)
* chore: traige state in intake

* chore: triage state changes

* feat: implement intake state dropdown component and integrate into issue properties

* chore: added the triage state validation

* chore: added triage state filter

* chore: added workspace filter

* fix: migration file

* chore: added triage group state check

* chore: updated the filters

* chore: updated the filters

* chore: added variables for intake state

* fix: import error

* refactor: improve project intake state retrieval logic and update TriageGroupIcon component

* chore: changed the intake validation logic

* refactor: update intake state types and clean up unused interfaces

* chore: changed the state color

* chore: changed the update serializer

* chore: updated with current instance

* chore: update TriageGroupIcon color to match new intake state group color

* chore: stringified value

* chore: added validation in serializer

* chore: added logger instead of print

* fix: correct component closing syntax in ActiveProjectItem

* chore: updated the migration file

* chore: added noop in migation

---------

Co-authored-by: b-saikrishnakanth <bsaikrishnakanth97@gmail.com>
2025-11-28 16:16:48 +05:30
b-saikrishnakanthandGitHub dbc5a6348d fix: disable timezone selection button for non-admin users (#8195) 2025-11-28 16:14:43 +05:30
Aaryan KhandelwalandGitHub c685042a47 regression: projects breadcrumb in accordion layout (#8194) 2025-11-28 14:05:38 +05:30
Vipin ChaudharyandGitHub a4de486cf7 [WIKI-811] fix: ensure only non-deleted project pages are retrieved in page queries (#8182)
* fix: ensure soft delete handling for pages in PageViewSet methods

* refactor: streamline query for project IDs in PageDuplicateEndpoint

* refactor: remove soft delete condition from ProjectPage queries in PageViewSet and PageDuplicateEndpoint

* refactor: simplify ProjectPage query in PageViewSet for improved readability

* refactor: replace filter with get for Page queries in PageViewSet and PageDuplicateEndpoint to enhance clarity

* refactor: replace filter with get for Page queries in PagesDescriptionViewSet to improve efficiency
2025-11-27 20:55:50 +05:30
Prateek ShouryaandGitHub 3c84e75350 [WEB-5510] fix: handle null values in context indicator for improved stability (#8190) 2025-11-27 20:53:42 +05:30
Aaryan KhandelwalandGitHub 39749106a2 regression: sidebar toggle button position (#8186)
* fix: sidebar toggle button position

* chore: remove border radius
2025-11-27 20:53:12 +05:30
Aaryan KhandelwalandGitHub 9bcb1fa469 [WEB-5515]: comments ordering (#8193)
* fix: comments ordering

* fix: comment timestamp:
2025-11-27 20:51:06 +05:30
Palanikannan M 006a08d35a fix: title editor added 2025-11-27 18:53:00 +05:30
SangeethaandGitHub c31a225775 [WEB-5506] fix: new navigation pre release bugs (#8181)
* chore: update navigation_project_limit and navigation_control_preference

* chore: set default true for user specific widgets

* chore: use serializer in ProjectMemberPreferenceEndpoint
chore: use serializer in WorkspaceUserPropertiesEndpoint
"

* fix: validate preferences

* fix: status code

* fix: remove saving from validate

* fix: simply validate_preferences

* chore: create WorkspaceUserProperties if it doesn't exist

* fix: create WorksapceUserProperties it not exist

* fix: copy the instance

* Revert "fix: copy the instance"

This reverts commit ddb0384b6d.

* chore: migrate WorkspaceUserPreference to set defaults

* fix: migration file name

* Revert "fix: migration file name"

This reverts commit 80a21dedf1.

* Revert "chore: migrate WorkspaceUserPreference to set defaults"

This reverts commit 25bc583a08.
2025-11-27 18:12:20 +05:30
Palanikannan M 66311499a3 fix: realtime sync working with failure handling 2025-11-21 19:27:01 +05:30
Palanikannan M bc3d499606 fix: robust way to handle socket connection and read from indexeddb cache when reqd 2025-11-21 17:48:33 +05:30
118 changed files with 3630 additions and 786 deletions
+47 -1
View File
@@ -1,7 +1,7 @@
# Module imports
from .base import BaseSerializer
from .issue import IssueExpandSerializer
from plane.db.models import IntakeIssue, Issue
from plane.db.models import IntakeIssue, Issue, State, StateGroup
from rest_framework import serializers
@@ -103,6 +103,52 @@ class IntakeIssueUpdateSerializer(BaseSerializer):
"updated_at",
]
def validate(self, attrs):
"""
Validate that if status is being changed to accepted (1),
the project has a default state to transition to.
"""
# Check if status is being updated to accepted
if attrs.get("status") == 1:
intake_issue = self.instance
issue = intake_issue.issue
# Check if issue is in TRIAGE state
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
# Verify default state exists before allowing the update
default_state = State.objects.filter(
workspace=intake_issue.workspace, project=intake_issue.project, default=True
).first()
if not default_state:
raise serializers.ValidationError(
{"status": "Cannot accept intake issue: No default state found for the project"}
)
return attrs
def update(self, instance, validated_data):
"""
Update intake issue and transition associated issue state if accepted.
"""
# Update the intake issue with validated data
instance = super().update(instance, validated_data)
# If status is accepted (1), update the associated issue state from TRIAGE to default
if validated_data.get("status") == 1:
issue = instance.issue
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
# Get the default project state
default_state = State.objects.filter(
workspace=instance.workspace, project=instance.project, default=True
).first()
if default_state:
issue.state = default_state
issue.save()
return instance
class IssueDataSerializer(serializers.Serializer):
"""
+5 -1
View File
@@ -1,6 +1,7 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import State
from plane.db.models import State, StateGroup
from rest_framework import serializers
class StateSerializer(BaseSerializer):
@@ -15,6 +16,9 @@ class StateSerializer(BaseSerializer):
# If the default is being provided then make all other states default False
if data.get("default", False):
State.objects.filter(project_id=self.context.get("project_id")).update(default=False)
if data.get("group", None) == StateGroup.TRIAGE.value:
raise serializers.ValidationError("Cannot create triage state")
return data
class Meta:
+63 -60
View File
@@ -23,7 +23,7 @@ from plane.api.serializers import (
)
from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State, StateGroup
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.db.models.intake import SourceType
@@ -165,6 +165,20 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
]:
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
# get the triage state
triage_state = State.triage_objects.filter(project_id=project_id, workspace__slug=slug).first()
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
project_id=project_id,
workspace_id=project.workspace_id,
color="#4E5355",
sequence=65000,
default=False,
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -172,6 +186,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
priority=request.data.get("issue", {}).get("priority", "none"),
project_id=project_id,
state_id=triage_state.id,
)
# create an intake issue
@@ -320,7 +335,10 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
# Get issue data
issue_data = request.data.pop("issue", False)
issue_serializer = None
intake_serializer = None
# Validate issue data if provided
if bool(issue_data):
issue = Issue.objects.annotate(
label_ids=Coalesce(
@@ -344,6 +362,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
Value([], output_field=ArrayField(UUIDField())),
),
).get(pk=issue_id, workspace__slug=slug, project_id=project_id)
# Only allow guests to edit name and description
if project_member.role <= 5:
issue_data = {
@@ -354,71 +373,55 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
issue_serializer = IssueSerializer(issue, data=issue_data, partial=True)
if issue_serializer.is_valid():
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
intake=(intake_issue.id),
)
issue_serializer.save()
else:
if not issue_serializer.is_valid():
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Only project admins and members can edit intake issue attributes
if project_member.role > 15:
serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
intake_serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
if not intake_serializer.is_valid():
return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Both serializers are valid, now save them
if issue_serializer:
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
intake=str(intake_issue.id),
)
issue_serializer.save()
# Save intake issue (state transition happens in serializer's update method)
if intake_serializer:
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
intake_serializer.save()
if serializer.is_valid():
serializer.save()
# Update the issue state if the issue is rejected or marked as duplicate
if serializer.data["status"] in [-1, 2]:
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
if state is not None:
issue.state = state
issue.save()
# Update the issue state if it is accepted
if serializer.data["status"] in [1]:
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
# Update the issue state only if it is in triage state
if issue.state.is_triage:
# Move to default state
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
if state is not None:
issue.state = state
issue.save()
# create a activity for status change
issue_activity.delay(
type="intake.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
serializer = IntakeIssueSerializer(intake_issue)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# create a activity for status change
issue_activity.delay(
type="intake.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
else:
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
+2 -36
View File
@@ -24,6 +24,7 @@ from plane.db.models import (
DeployBoard,
ProjectMember,
State,
DEFAULT_STATES,
Workspace,
UserFavorite,
)
@@ -232,41 +233,6 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
user_id=serializer.instance.project_lead,
)
# Default states
states = [
{
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": "cancelled",
},
]
State.objects.bulk_create(
[
State(
@@ -279,7 +245,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
default=state.get("default", False),
created_by=request.user,
)
for state in states
for state in DEFAULT_STATES
]
)
@@ -37,6 +37,7 @@ from .project import (
ProjectMemberAdminSerializer,
ProjectPublicMemberSerializer,
ProjectMemberRoleSerializer,
ProjectMemberPreferenceSerializer,
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, ViewIssueListSerializer
+44 -1
View File
@@ -7,7 +7,7 @@ from .issue import IssueIntakeSerializer, LabelLiteSerializer, IssueDetailSerial
from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
from plane.db.models import Intake, IntakeIssue, Issue
from plane.db.models import Intake, IntakeIssue, Issue, StateGroup, State
class IntakeSerializer(BaseSerializer):
@@ -36,6 +36,49 @@ class IntakeIssueSerializer(BaseSerializer):
]
read_only_fields = ["project", "workspace"]
def validate(self, attrs):
"""
Validate that if status is being changed to accepted (1),
the project has a default state to transition to.
"""
# Check if status is being updated to accepted
if attrs.get("status") == 1:
intake_issue = self.instance
issue = intake_issue.issue
# Check if issue is in TRIAGE state
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
# Verify default state exists before allowing the update
default_state = State.objects.filter(
workspace=intake_issue.workspace, project=intake_issue.project, default=True
).first()
if not default_state:
raise serializers.ValidationError(
{"status": "Cannot accept intake issue: No default state found for the project"}
)
return attrs
def update(self, instance, validated_data):
# Update the intake issue
instance = super().update(instance, validated_data)
# If status is accepted (1), transition the issue state from TRIAGE to default
if validated_data.get("status") == 1:
issue = instance.issue
if issue.state and issue.state.group == StateGroup.TRIAGE.value:
# Get the default project state
default_state = State.objects.filter(
workspace=instance.workspace, project=instance.project, default=True
).first()
if default_state:
issue.state = default_state
issue.save()
return instance
def to_representation(self, instance):
# Pass the annotated fields to the Issue instance if they exist
if hasattr(instance, "label_ids"):
+13 -2
View File
@@ -78,7 +78,7 @@ class IssueProjectLiteSerializer(BaseSerializer):
class IssueCreateSerializer(BaseSerializer):
# ids
state_id = serializers.PrimaryKeyRelatedField(
source="state", queryset=State.objects.all(), required=False, allow_null=True
source="state", queryset=State.all_state_objects.all(), required=False, allow_null=True
)
parent_id = serializers.PrimaryKeyRelatedField(
source="parent", queryset=Issue.objects.all(), required=False, allow_null=True
@@ -117,6 +117,9 @@ class IssueCreateSerializer(BaseSerializer):
return data
def validate(self, attrs):
allow_triage = self.context.get("allow_triage_state", False)
state_manager = State.triage_objects if allow_triage else State.objects
if (
attrs.get("start_date", None) is not None
and attrs.get("target_date", None) is not None
@@ -160,7 +163,7 @@ class IssueCreateSerializer(BaseSerializer):
# Check state is from the project only else raise validation error
if (
attrs.get("state")
and not State.objects.filter(
and not state_manager.filter(
project_id=self.context.get("project_id"),
pk=attrs.get("state").id,
).exists()
@@ -795,6 +798,14 @@ class IssueSerializer(DynamicBaseSerializer):
]
read_only_fields = fields
def validate(self, data):
if (
data.get("state_id")
and not State.objects.filter(project_id=self.context.get("project_id"), pk=data.get("state_id")).exists()
):
raise serializers.ValidationError("State is not valid please pass a valid state_id")
return data
class IssueListDetailSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
+12
View File
@@ -142,6 +142,18 @@ class ProjectMemberSerializer(BaseSerializer):
fields = "__all__"
class ProjectMemberPreferenceSerializer(BaseSerializer):
class Meta:
model = ProjectMember
fields = ["preferences", "project_id", "member_id", "workspace_id"]
def validate_preferences(self, value):
preferences = self.instance.preferences
preferences.update(value)
return preferences
class ProjectMemberAdminSerializer(BaseSerializer):
workspace = WorkspaceLiteSerializer(read_only=True)
project = ProjectLiteSerializer(read_only=True)
+6 -1
View File
@@ -2,7 +2,7 @@
from .base import BaseSerializer
from rest_framework import serializers
from plane.db.models import State
from plane.db.models import State, StateGroup
class StateSerializer(BaseSerializer):
@@ -24,6 +24,11 @@ class StateSerializer(BaseSerializer):
]
read_only_fields = ["workspace", "project"]
def validate(self, attrs):
if attrs.get("group") == StateGroup.TRIAGE.value:
raise serializers.ValidationError("Cannot create triage state")
return attrs
class StateLiteSerializer(BaseSerializer):
class Meta:
+6 -1
View File
@@ -1,7 +1,7 @@
from django.urls import path
from plane.app.views import StateViewSet
from plane.app.views import StateViewSet, IntakeStateEndpoint
urlpatterns = [
@@ -15,6 +15,11 @@ urlpatterns = [
StateViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="project-state",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-state/",
IntakeStateEndpoint.as_view(),
name="intake-state",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:pk>/mark-default/",
StateViewSet.as_view({"post": "mark_as_default"}),
+1 -1
View File
@@ -80,7 +80,7 @@ from .workspace.cycle import WorkspaceCyclesEndpoint
from .workspace.quick_link import QuickLinkViewSet
from .workspace.sticky import WorkspaceStickyViewSet
from .state.base import StateViewSet
from .state.base import StateViewSet, IntakeStateEndpoint
from .view.base import (
WorkspaceViewViewSet,
WorkspaceViewIssuesViewSet,
+96 -97
View File
@@ -22,6 +22,7 @@ from plane.db.models import (
IntakeIssue,
Issue,
State,
StateGroup,
IssueLink,
FileAsset,
Project,
@@ -228,14 +229,30 @@ class IntakeIssueViewSet(BaseViewSet):
]:
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
# create an issue
project = Project.objects.get(pk=project_id)
# get the triage state
triage_state = State.triage_objects.filter(project_id=project_id, workspace__slug=slug).first()
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
project_id=project_id,
workspace_id=project.workspace_id,
color="#4E5355",
sequence=65000,
default=False,
)
request.data["issue"]["state_id"] = triage_state.id
# create an issue
serializer = IssueCreateSerializer(
data=request.data.get("issue"),
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
"allow_triage_state": True,
},
)
if serializer.is_valid():
@@ -344,6 +361,12 @@ class IntakeIssueViewSet(BaseViewSet):
# Get issue data
issue_data = request.data.pop("issue", False)
issue_serializer = None
issue = None
issue_current_instance = None
issue_requested_data = None
# Validate issue data if provided
if bool(issue_data):
issue = Issue.objects.annotate(
label_ids=Coalesce(
@@ -371,119 +394,95 @@ class IntakeIssueViewSet(BaseViewSet):
"description": issue_data.get("description", issue.description),
}
current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
issue_current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
issue_requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True, context={"project_id": project_id}
issue, data=issue_data, partial=True, context={"project_id": project_id, "allow_triage_state": True}
)
if issue_serializer.is_valid():
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=current_instance,
issue_id=str(pk),
user_id=request.user.id,
)
issue_serializer.save()
else:
if not issue_serializer.is_valid():
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Only project admins can edit intake issue attributes
# Validate intake issue data if user has permission
intake_serializer = None
intake_current_instance = None
if (project_member and project_member.role > ROLE.MEMBER.value) or is_workspace_admin:
serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
if serializer.is_valid():
serializer.save()
# Update the issue state if the issue is rejected or marked as duplicate
if serializer.data["status"] in [-1, 2]:
issue = Issue.objects.get(
pk=intake_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
if state is not None:
issue.state = state
issue.save()
intake_current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
intake_serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
# Update the issue state if it is accepted
if serializer.data["status"] in [1]:
issue = Issue.objects.get(
pk=intake_issue.issue_id,
workspace__slug=slug,
project_id=project_id,
)
if not intake_serializer.is_valid():
return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Update the issue state only if it is in triage state
if issue.state.is_triage:
# Move to default state
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
if state is not None:
issue.state = state
issue.save()
# create a activity for status change
# Both serializers are valid, now save them
if issue_serializer:
issue_serializer.save()
# Log all the updates
if issue is not None:
issue_activity.delay(
type="intake.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
type="issue.activity.updated",
requested_data=issue_requested_data,
actor_id=str(request.user.id),
issue_id=str(pk),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=current_instance,
current_instance=issue_current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
notification=True,
origin=base_host(request=request, is_app=True),
intake=(intake_issue.id),
intake=str(intake_issue.id),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=issue_current_instance,
issue_id=str(pk),
user_id=request.user.id,
)
intake_issue = (
IntakeIssue.objects.select_related("issue")
.prefetch_related("issue__labels", "issue__assignees")
.annotate(
label_ids=Coalesce(
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=Q(
~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
if intake_serializer:
intake_serializer.save()
# create a activity for status change
issue_activity.delay(
type="intake.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=intake_current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
# Fetch and return the updated intake issue
intake_issue = (
IntakeIssue.objects.select_related("issue")
.prefetch_related("issue__labels", "issue__assignees")
.annotate(
label_ids=Coalesce(
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=Q(~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"issue__assignees__id",
distinct=True,
filter=Q(
~Q(issue__assignees__id__isnull=True) & Q(issue__issue_assignee__deleted_at__isnull=True)
),
assignee_ids=Coalesce(
ArrayAgg(
"issue__assignees__id",
distinct=True,
filter=Q(
~Q(issue__assignees__id__isnull=True)
& Q(issue__issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.get(intake_id=intake_id.id, issue_id=pk, project_id=project_id)
)
serializer = IntakeIssueDetailSerializer(intake_issue).data
return Response(serializer, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
serializer = IntakeIssueDetailSerializer(intake_issue).data
return Response(serializer, status=status.HTTP_200_OK)
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.get(intake_id=intake_id.id, issue_id=pk, project_id=project_id)
)
serializer = IntakeIssueDetailSerializer(intake_issue).data
return Response(serializer, status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue)
def retrieve(self, request, slug, project_id, pk):
+74 -21
View File
@@ -149,14 +149,24 @@ class PageViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, page_id):
try:
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
if page.is_locked:
return Response({"error": "Page is locked"}, status=status.HTTP_400_BAD_REQUEST)
parent = request.data.get("parent", None)
if parent:
_ = Page.objects.get(pk=parent, workspace__slug=slug, projects__id=project_id)
_ = Page.objects.get(
pk=parent,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
# Only update access if the page owner is the requesting user
if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id:
@@ -230,14 +240,24 @@ class PageViewSet(BaseViewSet):
return Response(data, status=status.HTTP_200_OK)
def lock(self, request, slug, project_id, page_id):
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page.is_locked = True
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def unlock(self, request, slug, project_id, page_id):
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page.is_locked = False
page.save()
@@ -246,7 +266,12 @@ class PageViewSet(BaseViewSet):
def access(self, request, slug, project_id, page_id):
access = request.data.get("access", 0)
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
# Only update access if the page owner is the requesting user
if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id:
@@ -277,7 +302,12 @@ class PageViewSet(BaseViewSet):
return Response(pages, status=status.HTTP_200_OK)
def archive(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
# only the owner or admin can archive the page
if (
@@ -303,7 +333,12 @@ class PageViewSet(BaseViewSet):
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
def unarchive(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
# only the owner or admin can un archive the page
if (
@@ -327,7 +362,12 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id)
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
if page.archived_at is None:
return Response(
@@ -350,7 +390,12 @@ class PageViewSet(BaseViewSet):
)
# remove parent from all the children
_ = Page.objects.filter(parent_id=page_id, projects__id=project_id, workspace__slug=slug).update(parent=None)
_ = Page.objects.filter(
parent_id=page_id,
projects__id=project_id,
workspace__slug=slug,
project_pages__deleted_at__isnull=True,
).update(parent=None)
page.delete()
# Delete the user favorite page
@@ -451,12 +496,14 @@ class PagesDescriptionViewSet(BaseViewSet):
def retrieve(self, request, slug, project_id, page_id):
page = (
Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
Page.objects.get(
Q(owned_by=self.request.user) | Q(access=0),
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
)
if page is None:
return Response({"error": "Page not found"}, status=404)
binary_data = page.description_binary
def stream_data():
@@ -471,14 +518,15 @@ class PagesDescriptionViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, page_id):
page = (
Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
Page.objects.get(
Q(owned_by=self.request.user) | Q(access=0),
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
)
if page is None:
return Response({"error": "Page not found"}, status=404)
if page.is_locked:
return Response(
{
@@ -529,7 +577,12 @@ class PageDuplicateEndpoint(BaseAPIView):
permission_classes = [ProjectPagePermission]
def post(self, request, slug, project_id, page_id):
page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first()
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
# check for permission
if page.access == Page.PRIVATE_ACCESS and page.owned_by_id != request.user.id:
+2 -36
View File
@@ -31,6 +31,7 @@ from plane.db.models import (
ProjectIdentifier,
ProjectMember,
State,
DEFAULT_STATES,
Workspace,
WorkspaceMember,
)
@@ -264,41 +265,6 @@ class ProjectViewSet(BaseViewSet):
user_id=serializer.data["project_lead"],
)
# Default states
states = [
{
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": "cancelled",
},
]
State.objects.bulk_create(
[
State(
@@ -311,7 +277,7 @@ class ProjectViewSet(BaseViewSet):
default=state.get("default", False),
created_by=request.user,
)
for state in states
for state in DEFAULT_STATES
]
)
+11 -15
View File
@@ -8,6 +8,7 @@ from plane.app.serializers import (
ProjectMemberSerializer,
ProjectMemberAdminSerializer,
ProjectMemberRoleSerializer,
ProjectMemberPreferenceSerializer,
)
from plane.app.permissions import WorkspaceUserPermission
@@ -303,7 +304,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
class ProjectMemberPreferenceEndpoint(BaseAPIView):
def get_project_member(self, slug, project_id, member_id):
def get_queryset(self, slug, project_id, member_id):
return ProjectMember.objects.get(
project_id=project_id,
member_id=member_id,
@@ -312,25 +313,20 @@ class ProjectMemberPreferenceEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def patch(self, request, slug, project_id, member_id):
project_member = self.get_project_member(slug, project_id, member_id)
project_member = self.get_queryset(slug, project_id, member_id)
current_preferences = project_member.preferences or {}
current_preferences["navigation"] = request.data["navigation"]
serializer = ProjectMemberPreferenceSerializer(project_member, {"preferences": request.data}, partial=True)
project_member.preferences = current_preferences
project_member.save(update_fields=["preferences"])
if serializer.is_valid():
serializer.save()
return Response({"preferences": project_member.preferences}, status=status.HTTP_200_OK)
return Response({"preferences": serializer.data["preferences"]}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, member_id):
project_member = self.get_project_member(slug, project_id, member_id)
project_member = self.get_queryset(slug, project_id, member_id)
response = {
"preferences": project_member.preferences,
"project_id": project_member.project_id,
"member_id": project_member.member_id,
"workspace_id": project_member.workspace_id,
}
serializer = ProjectMemberPreferenceSerializer(project_member)
return Response(response, status=status.HTTP_200_OK)
return Response(serializer.data, status=status.HTTP_200_OK)
+14 -1
View File
@@ -10,7 +10,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
from .. import BaseViewSet
from .. import BaseViewSet, BaseAPIView
from plane.app.serializers import StateSerializer
from plane.app.permissions import ROLE, allow_permission
from plane.db.models import State, Issue
@@ -127,3 +127,16 @@ class StateViewSet(BaseViewSet):
state.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class IntakeStateEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
state = State.triage_objects.filter(workspace__slug=slug, project_id=project_id).first()
if not state:
return Response(
{"error": "Triage state not found"},
status=status.HTTP_404_NOT_FOUND,
)
return Response(StateSerializer(state).data, status=status.HTTP_200_OK)
+13 -10
View File
@@ -249,23 +249,26 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission]
def patch(self, request, slug):
workspace_properties = WorkspaceUserProperties.objects.get(user=request.user, workspace__slug=slug)
workspace = Workspace.objects.get(slug=slug)
workspace_properties.filters = request.data.get("filters", workspace_properties.filters)
workspace_properties.rich_filters = request.data.get("rich_filters", workspace_properties.rich_filters)
workspace_properties.display_filters = request.data.get("display_filters", workspace_properties.display_filters)
workspace_properties.display_properties = request.data.get(
"display_properties", workspace_properties.display_properties
(workspace_properties, _) = WorkspaceUserProperties.objects.get_or_create(
user=request.user, workspace_id=workspace.id
)
workspace_properties.save()
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
return Response(serializer.data, status=status.HTTP_201_CREATED)
serializer = WorkspaceUserPropertiesSerializer(workspace_properties, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
(workspace_properties, _) = WorkspaceUserProperties.objects.get_or_create(
user=request.user, workspace__slug=slug
user=request.user, workspace=workspace
)
serializer = WorkspaceUserPropertiesSerializer(workspace_properties)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -39,6 +39,16 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
user=request.user,
workspace=workspace,
sort_order=(65535 + (i * 10000)),
is_pinned=(
True
if key
in [
WorkspaceUserPreference.UserPreferenceKeys.DRAFTS,
WorkspaceUserPreference.UserPreferenceKeys.YOUR_WORK,
WorkspaceUserPreference.UserPreferenceKeys.STICKIES,
]
else False
),
)
for i, key in enumerate(create_preference_keys)
],
+4 -1
View File
@@ -17,6 +17,7 @@ from plane.db.models import (
Project,
ProjectMember,
State,
StateGroup,
Label,
Cycle,
Module,
@@ -264,7 +265,9 @@ def create_issues(workspace, project, user_id, issue_count):
Faker.seed(0)
states = (
State.objects.filter(workspace=workspace, project=project).exclude(group="Triage").values_list("id", flat=True)
State.objects.filter(workspace=workspace, project=project)
.exclude(group=StateGroup.TRIAGE.value)
.values_list("id", flat=True)
)
creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True)
@@ -0,0 +1,92 @@
# Generated by Django 4.2.25 on 2025-11-24 06:03
from django.db import migrations, transaction
from django.db.models import OuterRef, Subquery
import logging
logger = logging.getLogger("plane.migrations")
BATCH_SIZE = 4000
def create_triage_state(apps, _schema_editor):
Project = apps.get_model("db", "Project")
State = apps.get_model("db", "State")
Issue = apps.get_model("db", "Issue")
# 1) Bulk-update existing triage states
triage_qs = State.objects.filter(group="triage")
projects_with_triage_state = list(triage_qs.values_list("project_id", flat=True))
triage_qs.update(
name="Triage",
color="#4E5355",
sequence=65000,
default=False,
)
logger.info(f"Updated {triage_qs.count()} triage states.")
# 2) Projects that already have a 'Triage' name but not in triage group
projects_to_update_qs = (
State.objects.exclude(group="triage")
.filter(name="Triage")
.values_list("project_id", flat=True)
)
projects_to_update = set(projects_to_update_qs)
logger.info(f"Projects to update: {len(projects_to_update)}")
# 3) Create missing triage states in chunks to avoid memory spike
states_to_create = []
project_iter = Project.objects.all().values_list("id", "workspace_id").iterator()
for proj_id, workspace_id in project_iter:
if proj_id in projects_with_triage_state:
continue
if proj_id in projects_to_update:
name = f"Triage-{str(proj_id)[:5]}"
else:
name = "Triage"
states_to_create.append(
State(
name=name,
group="triage",
project_id=proj_id,
workspace_id=workspace_id,
color="#4E5355",
sequence=65000,
default=False,
)
)
if len(states_to_create) >= BATCH_SIZE:
State.objects.bulk_create(states_to_create, batch_size=BATCH_SIZE)
states_to_create = []
if states_to_create:
State.objects.bulk_create(states_to_create, batch_size=BATCH_SIZE)
# 4) Update issues: use deterministic subquery and only update issues that will get a triage state.
with transaction.atomic():
triage_state_subquery = (
State.objects.filter(
group="triage",
project_id=OuterRef("project_id"),
workspace_id=OuterRef("workspace_id"),
)
.values("id")[:1]
)
updated_count = Issue._default_manager.filter(
issue_intake__status__in=[-2, 0],
).update(state_id=Subquery(triage_state_subquery))
logger.info(f"Updated {updated_count} issues.")
class Migration(migrations.Migration):
dependencies = [
('db', '0111_notification_notif_receiver_status_idx_and_more'),
]
operations = [
migrations.RunPython(create_triage_state,
reverse_code=migrations.RunPython.noop),
]
+1 -1
View File
@@ -56,7 +56,7 @@ from .project import (
)
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State
from .state import State, StateGroup, DEFAULT_STATES
from .user import Account, Profile, User
from .view import IssueView
from .webhook import Webhook, WebhookLog
+2
View File
@@ -19,6 +19,7 @@ from .project import ProjectBaseModel
from plane.utils.uuid import convert_uuid_to_integer
from .description import Description
from plane.db.mixins import ChangeTrackerMixin
from .state import StateGroup
def get_default_properties():
@@ -97,6 +98,7 @@ class IssueManager(SoftDeletionManager):
)
.filter(deleted_at__isnull=True)
.filter(state__is_triage=False)
.exclude(state__group=StateGroup.TRIAGE.value)
.exclude(archived_at__isnull=False)
.exclude(project__archived_at__isnull=False)
.exclude(is_draft=True)
+71 -9
View File
@@ -7,6 +7,71 @@ from django.db.models import Q
from .project import ProjectBaseModel
class StateGroup(models.TextChoices):
BACKLOG = "backlog", "Backlog"
UNSTARTED = "unstarted", "Unstarted"
STARTED = "started", "Started"
COMPLETED = "completed", "Completed"
CANCELLED = "cancelled", "Cancelled"
TRIAGE = "triage", "Triage"
# Default states
DEFAULT_STATES = [
{
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": StateGroup.BACKLOG.value,
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": StateGroup.UNSTARTED.value,
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": StateGroup.STARTED.value,
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": StateGroup.COMPLETED.value,
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": StateGroup.CANCELLED.value,
},
{
"name": "Triage",
"color": "#4E5355",
"sequence": 65000,
"group": StateGroup.TRIAGE.value,
},
]
class StateManager(models.Manager):
"""Default manager - excludes triage states"""
def get_queryset(self):
return super().get_queryset().exclude(group=StateGroup.TRIAGE.value)
class TriageStateManager(models.Manager):
"""Manager for triage states only"""
def get_queryset(self):
return super().get_queryset().filter(group=StateGroup.TRIAGE.value)
class State(ProjectBaseModel):
name = models.CharField(max_length=255, verbose_name="State Name")
description = models.TextField(verbose_name="State Description", blank=True)
@@ -14,15 +79,8 @@ class State(ProjectBaseModel):
slug = models.SlugField(max_length=100, blank=True)
sequence = models.FloatField(default=65535)
group = models.CharField(
choices=(
("backlog", "Backlog"),
("unstarted", "Unstarted"),
("started", "Started"),
("completed", "Completed"),
("cancelled", "Cancelled"),
("triage", "Triage"),
),
default="backlog",
choices=StateGroup.choices,
default=StateGroup.BACKLOG,
max_length=20,
)
is_triage = models.BooleanField(default=False)
@@ -30,6 +88,10 @@ class State(ProjectBaseModel):
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
objects = StateManager()
all_state_objects = models.Manager()
triage_objects = TriageStateManager()
def __str__(self):
"""Return name of the state"""
return f"{self.name} <{self.project.name}>"
+5
View File
@@ -76,5 +76,10 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.migrations": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}
+5
View File
@@ -86,5 +86,10 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.migrations": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}
+19 -2
View File
@@ -12,7 +12,7 @@ from rest_framework.response import Response
# Module imports
from .base import BaseViewSet
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard, State, StateGroup
from plane.app.serializers import (
IssueSerializer,
IntakeIssueSerializer,
@@ -121,6 +121,22 @@ class IntakeIssuePublicViewSet(BaseViewSet):
]:
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
# get the triage state
triage_state = State.triage_objects.filter(
project_id=project_deploy_board.project_id, workspace_id=project_deploy_board.workspace_id
).first()
if not triage_state:
triage_state = State.objects.create(
name="Triage",
group=StateGroup.TRIAGE.value,
project_id=project_deploy_board.project_id,
workspace_id=project_deploy_board.workspace_id,
color="#4E5355",
sequence=65000,
default=False,
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -128,6 +144,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_deploy_board.project_id,
state_id=triage_state.id,
)
# Create an Issue Activity
@@ -191,7 +208,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
issue,
data=issue_data,
partial=True,
context={"project_id": project_deploy_board.project_id},
context={"project_id": project_deploy_board.project_id, "allow_triage_state": True},
)
if issue_serializer.is_valid():
+15 -2
View File
@@ -27,6 +27,17 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
const pageDetails = await service.fetchDetails(pageId);
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
if (convertedBinaryData) {
// save the converted binary data back to the database
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
convertedBinaryData,
true
);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await service.updateDescriptionBinary(pageId, payload);
return convertedBinaryData;
}
}
@@ -52,8 +63,10 @@ const storeDocument = async ({
try {
const service = getPageService(context.documentType, context);
// convert binary data to all formats
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(pageBinaryData);
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
pageBinaryData,
true
);
// create payload
const payload = {
description_binary: contentBinaryEncoded,
+176
View File
@@ -0,0 +1,176 @@
// hocuspocus
import type { Extension, Hocuspocus, Document } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import type * as Y from "yjs";
// editor extensions
import { TITLE_EDITOR_EXTENSIONS, createRealtimeEvent } from "@plane/editor";
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
// helpers
import { getPageService } from "@/services/page/handler";
import type { HocusPocusServerContext, OnLoadDocumentPayloadWithContext } from "@/types";
import { generateTitleProsemirrorJson } from "@/utils";
import { broadcastMessageToPage } from "@/utils/broadcast-message";
import { TitleUpdateManager } from "./title-update/title-update-manager";
import { extractTextFromHTML } from "./title-update/title-utils";
/**
* Hocuspocus extension for synchronizing document titles
*/
export class TitleSyncExtension implements Extension {
// Maps document names to their observers and update managers
private titleObservers: Map<string, (events: Y.YEvent<any>[]) => void> = new Map();
private titleUpdateManagers: Map<string, TitleUpdateManager> = new Map();
// Store minimal data needed for each document's title observer (prevents closure memory leaks)
private titleObserverData: Map<
string,
{
parentId?: string | null;
userId: string;
workspaceSlug: string | null;
instance: Hocuspocus;
}
> = new Map();
/**
* Handle document loading - migrate old titles if needed
*/
async onLoadDocument({ context, document, documentName }: OnLoadDocumentPayloadWithContext) {
try {
// initially for on demand migration of old titles to a new title field
// in the yjs binary
if (document.isEmpty("title")) {
const service = getPageService(context.documentType, context);
// const title = await service.fe
const title = (await service.fetchDetails?.(documentName)).name;
if (title == null) return;
const titleField = TiptapTransformer.toYdoc(
generateTitleProsemirrorJson(title),
"title",
// editor
TITLE_EDITOR_EXTENSIONS as any
);
document.merge(titleField);
}
} catch (error) {
const appError = new AppError(error, {
context: { operation: "onLoadDocument", documentName },
});
logger.error("Error loading document title", appError);
}
}
/**
* Set up title synchronization for a document after it's loaded
*/
async afterLoadDocument({
document,
documentName,
context,
instance,
}: {
document: Document;
documentName: string;
context: HocusPocusServerContext;
instance: Hocuspocus;
}) {
// Create a title update manager for this document
const updateManager = new TitleUpdateManager(documentName, context);
// Store the manager
this.titleUpdateManagers.set(documentName, updateManager);
// Store minimal data needed for the observer (prevents closure memory leak)
this.titleObserverData.set(documentName, {
parentId: context.parentId,
userId: context.userId,
workspaceSlug: context.workspaceSlug,
instance: instance,
});
// Create observer using bound method to avoid closure capturing heavy objects
const titleObserver = this.handleTitleChange.bind(this, documentName);
// Observe the title field
document.getXmlFragment("title").observeDeep(titleObserver);
this.titleObservers.set(documentName, titleObserver);
}
/**
* Handle title changes for a document
* This is a separate method to avoid closure memory leaks
*/
private handleTitleChange(documentName: string, events: Y.YEvent<any>[]) {
let title = "";
events.forEach((event) => {
title = extractTextFromHTML(event.currentTarget.toJSON());
});
// Get the manager for this document
const manager = this.titleUpdateManagers.get(documentName);
// Get the stored data for this document
const data = this.titleObserverData.get(documentName);
// Broadcast to parent page if it exists
if (data?.parentId && data.workspaceSlug && data.instance) {
const event = createRealtimeEvent({
user_id: data.userId,
workspace_slug: data.workspaceSlug,
action: "property_updated",
page_id: documentName,
data: { name: title },
descendants_ids: [],
});
// Use the instance from stored data (guaranteed to be set)
broadcastMessageToPage(data.instance, data.parentId, event);
}
// Schedule the title update
if (manager) {
manager.scheduleUpdate(title);
}
}
/**
* Force save title before unloading the document
*/
async beforeUnloadDocument({ documentName }: { documentName: string }) {
const updateManager = this.titleUpdateManagers.get(documentName);
if (updateManager) {
// Force immediate save and wait for it to complete
await updateManager.forceSave();
// Clean up the manager
this.titleUpdateManagers.delete(documentName);
}
}
/**
* Remove observers after document unload
*/
async afterUnloadDocument({ documentName, document }: { documentName: string; document?: Document }) {
// Clean up observer when document is unloaded
const observer = this.titleObservers.get(documentName);
if (observer) {
// unregister observer from Y.js document to prevent memory leak
if (document) {
try {
document.getXmlFragment("title").unobserveDeep(observer);
} catch (error) {
logger.error("Failed to unobserve title field", new AppError(error, { context: { documentName } }));
}
}
this.titleObservers.delete(documentName);
}
// Clean up the observer data map to prevent memory leak
this.titleObserverData.delete(documentName);
// Ensure manager is cleaned up if beforeUnloadDocument somehow didn't run
if (this.titleUpdateManagers.has(documentName)) {
const manager = this.titleUpdateManagers.get(documentName)!;
manager.cancel();
this.titleUpdateManagers.delete(documentName);
}
}
}
@@ -0,0 +1,277 @@
import { logger } from "@plane/logger";
/**
* DebounceState - Tracks the state of a debounced function
*/
export interface DebounceState {
lastArgs: any[] | null;
timerId: ReturnType<typeof setTimeout> | null;
lastCallTime: number | undefined;
lastExecutionTime: number;
inProgress: boolean;
abortController: AbortController | null;
}
/**
* Creates a new DebounceState object
*/
export const createDebounceState = (): DebounceState => ({
lastArgs: null,
timerId: null,
lastCallTime: undefined,
lastExecutionTime: 0,
inProgress: false,
abortController: null,
});
/**
* DebounceOptions - Configuration options for debounce
*/
export interface DebounceOptions {
/** The wait time in milliseconds */
wait: number;
/** Optional logging prefix for debug messages */
logPrefix?: string;
}
/**
* Enhanced debounce manager with abort support
* Manages the state and timing of debounced function calls
*/
export class DebounceManager {
private state: DebounceState;
private wait: number;
private logPrefix: string;
/**
* Creates a new DebounceManager
* @param options Debounce configuration options
*/
constructor(options: DebounceOptions) {
this.state = createDebounceState();
this.wait = options.wait;
this.logPrefix = options.logPrefix || "";
}
/**
* Schedule a debounced function call
* @param func The function to call
* @param args The arguments to pass to the function
*/
schedule(func: (...args: any[]) => Promise<void>, ...args: any[]): void {
// Always update the last arguments
this.state.lastArgs = args;
const time = Date.now();
this.state.lastCallTime = time;
// If an operation is in progress, just store the new args and start the timer
if (this.state.inProgress) {
// Always restart the timer for the new call, even if an operation is in progress
if (this.state.timerId) {
clearTimeout(this.state.timerId);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// If already scheduled, update the args and restart the timer
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// Start the timer for the trailing edge execution
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
/**
* Called when the timer expires
*/
private timerExpired(func: (...args: any[]) => Promise<void>): void {
const time = Date.now();
// Check if this timer expiration represents the end of the debounce period
if (this.shouldInvoke(time)) {
// Execute the function
this.executeFunction(func, time);
return;
}
// Otherwise restart the timer
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.remainingWait(time));
}
/**
* Execute the debounced function
*/
private executeFunction(func: (...args: any[]) => Promise<void>, time: number): void {
this.state.timerId = null;
this.state.lastExecutionTime = time;
// Execute the function asynchronously
this.performFunction(func).catch((error) => {
logger.error(`${this.logPrefix}: Error in execution:`, error);
});
}
/**
* Perform the actual function call, handling any in-progress operations
*/
private async performFunction(func: (...args: any[]) => Promise<void>): Promise<void> {
const args = this.state.lastArgs;
if (!args) return;
// Store the args we're about to use
const currentArgs = [...args];
// If another operation is in progress, abort it
await this.abortOngoingOperation();
// Mark that we're starting a new operation
this.state.inProgress = true;
this.state.abortController = new AbortController();
try {
// Add the abort signal to the arguments if the function can use it
const execArgs = [...currentArgs];
execArgs.push(this.state.abortController.signal);
await func(...execArgs);
// Only clear lastArgs if they haven't been changed during this operation
if (this.state.lastArgs && this.arraysEqual(this.state.lastArgs, currentArgs)) {
this.state.lastArgs = null;
// Clear any timer as we've successfully processed the latest args
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
} else if (this.state.lastArgs) {
// If lastArgs have changed during this operation, the timer should already be running
// but let's make sure it is
if (!this.state.timerId) {
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// Nothing to do here, the new operation will be triggered by the timer expiration
} else {
logger.error(`${this.logPrefix}: Error during operation:`, error);
// On error (not abort), make sure we have a timer running to retry
if (!this.state.timerId && this.state.lastArgs) {
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} finally {
this.state.inProgress = false;
this.state.abortController = null;
}
}
/**
* Abort any ongoing operation
*/
private async abortOngoingOperation(): Promise<void> {
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
// Small delay to ensure the abort has had time to propagate
await new Promise((resolve) => setTimeout(resolve, 20));
// Double-check that state has been reset, force it if not
if (this.state.inProgress || this.state.abortController) {
this.state.inProgress = false;
this.state.abortController = null;
}
}
}
/**
* Determine if we should invoke the function now
*/
private shouldInvoke(time: number): boolean {
// Either this is the first call, or we've waited long enough since the last call
return this.state.lastCallTime === undefined || time - this.state.lastCallTime >= this.wait;
}
/**
* Calculate how much longer we should wait
*/
private remainingWait(time: number): number {
const timeSinceLastCall = time - (this.state.lastCallTime || 0);
return Math.max(0, this.wait - timeSinceLastCall);
}
/**
* Force immediate execution
*/
async flush(func: (...args: any[]) => Promise<void>): Promise<void> {
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Perform the function immediately
if (this.state.lastArgs) {
await this.performFunction(func);
}
}
/**
* Cancel any pending operations without executing
*/
cancel(): void {
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Abort any in-progress operation
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
this.state.inProgress = false;
this.state.abortController = null;
}
// Clear args
this.state.lastArgs = null;
}
/**
* Compare two arrays for equality
*/
private arraysEqual(a: any[], b: any[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
}
@@ -0,0 +1,90 @@
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
import { getPageService } from "@/services/page/handler";
import type { HocusPocusServerContext } from "@/types";
import { DebounceManager } from "./debounce";
/**
* Manages title update operations for a single document
* Handles debouncing, aborting, and force saving title updates
*/
export class TitleUpdateManager {
private documentName: string;
private context: HocusPocusServerContext;
private debounceManager: DebounceManager;
private lastTitle: string | null = null;
/**
* Create a new TitleUpdateManager instance
*/
constructor(documentName: string, context: HocusPocusServerContext, wait: number = 5000) {
this.documentName = documentName;
this.context = context;
// Set up debounce manager with logging
this.debounceManager = new DebounceManager({
wait,
logPrefix: `TitleManager[${documentName.substring(0, 8)}]`,
});
}
/**
* Schedule a debounced title update
*/
scheduleUpdate(title: string): void {
// Store the latest title
this.lastTitle = title;
// Schedule the update with the debounce manager
this.debounceManager.schedule(this.updateTitle.bind(this), title);
}
/**
* Update the title - will be called by the debounce manager
*/
private async updateTitle(title: string, signal?: AbortSignal): Promise<void> {
const service = getPageService(this.context.documentType, this.context);
if (!service.updatePageProperties) {
logger.warn(`No updateTitle method found for document ${this.documentName}`);
return;
}
try {
await service.updatePageProperties(this.documentName, {
data: { name: title },
abortSignal: signal,
});
// Clear last title only if it matches what we just updated
if (this.lastTitle === title) {
this.lastTitle = null;
}
} catch (error) {
const appError = new AppError(error, {
context: { operation: "updateTitle", documentName: this.documentName },
});
logger.error("Error updating title", appError);
}
}
/**
* Force save the current title immediately
*/
async forceSave(): Promise<void> {
// Ensure we have the current title
if (!this.lastTitle) {
return;
}
// Use the debounce manager to flush the operation
await this.debounceManager.flush(this.updateTitle.bind(this));
}
/**
* Cancel any pending updates
*/
cancel(): void {
this.debounceManager.cancel();
this.lastTitle = null;
}
}
@@ -0,0 +1,8 @@
/**
* Utility function to extract text from HTML content
*/
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
@@ -2,9 +2,13 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { Header, Row } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { AppHeader } from "@/components/core/app-header";
import { TabNavigationRoot } from "@/components/navigation";
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
// local components
@@ -14,6 +18,7 @@ export const ProjectWorkItemDetailsHeader = observer(function ProjectWorkItemDet
// router
const { workspaceSlug, workItem } = useParams();
// store hooks
const { sidebarCollapsed } = useAppTheme();
const {
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail();
@@ -29,8 +34,13 @@ export const ProjectWorkItemDetailsHeader = observer(function ProjectWorkItemDet
<div className="z-20">
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
<div className="flex items-center gap-2 divide-x divide-custom-border-100 h-full w-full">
<div className="flex items-center h-full w-full flex-1">
<Header className="h-full">
<div className="flex items-center gap-2 size-full flex-1">
{sidebarCollapsed && (
<div className="shrink-0">
<AppSidebarToggleButton />
</div>
)}
<Header className={cn("h-full", { "pl-1.5": !sidebarCollapsed })}>
<Header.LeftItem className="h-full max-w-full">
<TabNavigationRoot
workspaceSlug={workspaceSlug}
@@ -13,6 +13,8 @@ import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const WorkItemDetailsHeader = observer(() => {
// router
@@ -34,6 +36,7 @@ export const WorkItemDetailsHeader = observer(() => {
<Header>
<Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
@@ -1,4 +1,4 @@
import React, { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
@@ -76,7 +76,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
EUserPermissionsLevel.WORKSPACE
);
const handleClose = () => toggleExtendedProjectSidebar(false);
const handleClose = useCallback(() => toggleExtendedProjectSidebar(false), [toggleExtendedProjectSidebar]);
const handleCopyText = (projectId: string) => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
@@ -102,8 +102,9 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
extendedSidebarRef={extendedProjectSidebarRef}
handleClose={handleClose}
excludedElementId="extended-project-sidebar-toggle"
className="px-0"
>
<div className="flex flex-col gap-1 w-full sticky top-4 pt-0">
<div className="flex flex-col gap-1 w-full sticky top-4 px-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-custom-text-300 py-1.5">Projects</span>
{isAuthorizedUser && (
@@ -111,7 +112,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
<button
type="button"
data-ph-element={PROJECT_TRACKER_ELEMENTS.EXTENDED_SIDEBAR_ADD_BUTTON}
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0 text-custom-text-300 hover:text-custom-text-200 transition-colors"
onClick={() => {
setIsProjectModalOpen(true);
}}
@@ -133,7 +134,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
</div>
</div>
{filteredProjects.length === 0 ? (
<div className="flex flex-col items-center mt-4 px-6 pt-10">
<div className="flex flex-col items-center mt-4 p-10">
<EmptyStateCompact
title={t("common_empty_state.search.title")}
description={t("common_empty_state.search.description")}
@@ -143,7 +144,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
/>
</div>
) : (
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 pl-4">
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 pl-9 pr-2">
{filteredProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
@@ -1,14 +1,16 @@
import type { FC } from "react";
import React from "react";
import React, { useEffect } from "react";
import { observer } from "mobx-react";
// plane imports
import { EXTENDED_SIDEBAR_WIDTH, SIDEBAR_WIDTH } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { cn } from "@plane/utils";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
// hooks
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
type Props = {
className?: string;
children: React.ReactNode;
extendedSidebarRef: React.RefObject<HTMLDivElement>;
isExtendedSidebarOpened: boolean;
@@ -17,26 +19,35 @@ type Props = {
};
export const ExtendedSidebarWrapper = observer(function ExtendedSidebarWrapper(props: Props) {
const { children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props;
const { className, children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props;
// store hooks
const { sidebarCollapsed } = useAppTheme();
// local storage
const { storedValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH);
useExtendedSidebarOutsideClickDetector(extendedSidebarRef, handleClose, excludedElementId);
useEffect(() => {
if (sidebarCollapsed) {
handleClose();
}
}, [sidebarCollapsed, handleClose]);
return (
<div
id={excludedElementId}
ref={extendedSidebarRef}
className={cn(
`absolute h-full z-[21] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm`,
"absolute h-full z-[21] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm",
{
"translate-x-0 opacity-100": isExtendedSidebarOpened,
[`-translate-x-[${EXTENDED_SIDEBAR_WIDTH}px] opacity-0 hidden`]: !isExtendedSidebarOpened,
}
"opacity-100": isExtendedSidebarOpened,
"opacity-0 hidden": !isExtendedSidebarOpened,
},
className
)}
style={{
left: `${storedValue ?? SIDEBAR_WIDTH}px`,
width: `${isExtendedSidebarOpened ? EXTENDED_SIDEBAR_WIDTH : 0}px`,
width: `${EXTENDED_SIDEBAR_WIDTH}px`,
}}
>
{children}
@@ -1,4 +1,4 @@
import React, { useMemo, useRef } from "react";
import { useCallback, useMemo, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
@@ -107,7 +107,7 @@ export const ExtendedAppSidebar = observer(function ExtendedAppSidebar() {
if (updatedSortOrder != undefined) updateWorkspaceItemSortOrder(sourceId, updatedSortOrder);
};
const handleClose = () => toggleExtendedSidebar(false);
const handleClose = useCallback(() => toggleExtendedSidebar(false), [toggleExtendedSidebar]);
return (
<ExtendedSidebarWrapper
@@ -1,4 +1,3 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ArchiveIcon, CycleIcon, ModuleIcon, WorkItemsIcon } from "@plane/propel/icons";
@@ -13,8 +12,8 @@ import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs/project";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
type TProps = {
activeTab: "issues" | "cycles" | "modules";
@@ -67,7 +66,7 @@ export const ProjectArchivesHeader = observer(function ProjectArchivesHeader(pro
<Header.LeftItem>
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<ProjectBreadcrumb workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
@@ -41,6 +41,7 @@ import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import useLocalStorage from "@/hooks/use-local-storage";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const CycleIssuesHeader = observer(function CycleIssuesHeader() {
// refs
@@ -134,6 +135,7 @@ export const CycleIssuesHeader = observer(function CycleIssuesHeader() {
<Header.LeftItem>
<div className="flex items-center gap-2">
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
@@ -1,4 +1,3 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
@@ -15,11 +14,13 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const CyclesListHeader = observer(function CyclesListHeader() {
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
const { workspaceSlug, projectId } = useParams();
// store hooks
const { toggleCreateCycleModal } = useCommandPalette();
@@ -36,6 +37,7 @@ export const CyclesListHeader = observer(function CyclesListHeader() {
<Header>
<Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
@@ -1,14 +1,24 @@
"use client";
import { observer } from "mobx-react";
import { Outlet } from "react-router";
// plane imports
import { Header, Row } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { TabNavigationRoot } from "@/components/navigation/tab-navigation-root";
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
// local imports
import type { Route } from "./+types/layout";
export default function ProjectLayout({ params }: Route.ComponentProps) {
function ProjectLayout({ params }: Route.ComponentProps) {
// router
const { workspaceSlug, projectId } = params;
// store hooks
const { sidebarCollapsed } = useAppTheme();
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
@@ -18,9 +28,14 @@ export default function ProjectLayout({ params }: Route.ComponentProps) {
<div className="z-20">
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
<div className="flex items-center gap-2 divide-x divide-custom-border-100 h-full w-full">
<div className="flex items-center h-full w-full flex-1">
<Header className="h-full">
<Header.LeftItem className="h-full max-w-full">
<div className="flex items-center gap-2 size-full flex-1">
{sidebarCollapsed && (
<div className="shrink-0">
<AppSidebarToggleButton />
</div>
)}
<Header className={cn("h-full", { "pl-1.5": !sidebarCollapsed })}>
<Header.LeftItem className="h-full max-w-full flex items-center gap-2">
<TabNavigationRoot workspaceSlug={workspaceSlug} projectId={projectId} />
</Header.LeftItem>
</Header>
@@ -33,3 +48,5 @@ export default function ProjectLayout({ params }: Route.ComponentProps) {
</>
);
}
export default observer(ProjectLayout);
@@ -40,6 +40,8 @@ import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesActions } from "@/hooks/use-issues-actions";
import useLocalStorage from "@/hooks/use-local-storage";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() {
// refs
@@ -126,6 +128,7 @@ export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() {
<Header.LeftItem>
<div className="flex items-center gap-2">
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
@@ -15,6 +15,8 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ModulesListHeader = observer(function ModulesListHeader() {
// router
@@ -39,6 +41,7 @@ export const ModulesListHeader = observer(function ModulesListHeader() {
<Header.LeftItem>
<div>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
@@ -1,23 +1,22 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { PageIcon } from "@plane/propel/icons";
// types
import type { ICustomSearchSelectOption } from "@plane/types";
// ui
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
// components
import { getPageName } from "@plane/utils";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { PageAccessIcon } from "@/components/common/page-access-icon";
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
import { PageHeaderActions } from "@/components/pages/header/actions";
// helpers
import { PageSyncingBadge } from "@/components/pages/header/syncing-badge";
// hooks
import { useProject } from "@/hooks/store/use-project";
// plane web components
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
// plane web hooks
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
export interface IPagesHeaderProps {
@@ -64,6 +63,7 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
<Header.LeftItem>
<div>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
@@ -96,6 +96,7 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
</div>
</Header.LeftItem>
<Header.RightItem>
<PageSyncingBadge syncStatus={page.isSyncingWithServer} />
<PageDetailsHeaderExtraActions page={page} storeType={storeType} />
<PageHeaderActions page={page} storeType={storeType} />
</Header.RightItem>
@@ -15,7 +15,8 @@ import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useProject } from "@/hooks/store/use-project";
// plane web hooks
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
export const PagesListHeader = observer(function PagesListHeader() {
@@ -23,7 +24,7 @@ export const PagesListHeader = observer(function PagesListHeader() {
const [isCreatingPage, setIsCreatingPage] = useState(false);
// router
const router = useRouter();
const { workspaceSlug } = useParams();
const { workspaceSlug, projectId } = useParams();
const searchParams = useSearchParams();
const pageType = searchParams.get("type");
// store hooks
@@ -69,6 +70,7 @@ export const PagesListHeader = observer(function PagesListHeader() {
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
@@ -2,7 +2,7 @@ import { useCallback, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Lock } from "lucide-react";
// plane constants
// plane imports
import {
EIssueFilterType,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
@@ -10,19 +10,16 @@ import {
EUserPermissionsLevel,
WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants";
// types
import { Button } from "@plane/propel/button";
import { ViewsIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssuesStoreType, EViewAccess, EIssueLayoutTypes } from "@plane/types";
// ui
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters";
// constants
import { ViewQuickActions } from "@/components/views/quick-actions";
import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle";
// hooks
@@ -31,8 +28,9 @@ import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useUserPermissions } from "@/hooks/store/user";
// plane web
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ProjectViewIssuesHeader = observer(function ProjectViewIssuesHeader() {
// refs
@@ -120,6 +118,7 @@ export const ProjectViewIssuesHeader = observer(function ProjectViewIssuesHeader
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
@@ -11,6 +11,8 @@ import { ViewListHeader } from "@/components/views/view-list-header";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ProjectViewsHeader = observer(function ProjectViewsHeader() {
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
@@ -23,6 +25,7 @@ export const ProjectViewsHeader = observer(function ProjectViewsHeader() {
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
@@ -0,0 +1,17 @@
// local components
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
import { ProjectBreadcrumb } from "./project";
type TCommonProjectBreadcrumbProps = {
workspaceSlug: string;
projectId: string;
};
export function CommonProjectBreadcrumbs(props: TCommonProjectBreadcrumbProps) {
const { workspaceSlug, projectId } = props;
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
if (projectPreferences.navigationMode === "horizontal") return null;
return <ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />;
}
@@ -49,7 +49,7 @@ export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TPro
// helpers
const renderIcon = (projectDetails: TProject) => (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<span className="grid place-items-center flex-shrink-0 size-4">
<Logo logo={projectDetails.logo_props} size={14} />
</span>
);
@@ -1,8 +1,8 @@
// local imports
import type { TPowerKContextTypeExtended } from "../types";
import type { TPowerKContextType } from "@/components/power-k/core/types";
type TArgs = {
activeContext: TPowerKContextTypeExtended | null;
activeContext: TPowerKContextType | null;
};
export const useExtendedContextIndicator = (_args: TArgs): string | null => null;
@@ -64,7 +64,7 @@ export const CommentBlock = observer(function CommentBlock(props: TCommentBlock)
position="bottom"
>
<span className="text-custom-text-350">
{calculateTimeAgo(comment.updated_at)}
{calculateTimeAgo(comment.created_at)}
{comment.edited_at && ` (${t("edited")})`}
</span>
</Tooltip>
@@ -1,16 +1,26 @@
import type { ReactNode } from "react";
import { observer } from "mobx-react";
import { useParams } from "react-router";
// components
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
export const ExtendedAppHeader = observer(function ExtendedAppHeader(props: { header: ReactNode }) {
const { header } = props;
// params
const { projectId, workItem } = useParams();
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
// store hooks
const { sidebarCollapsed } = useAppTheme();
// derived values
const shouldShowSidebarToggleButton = projectPreferences.navigationMode === "accordion" || (!projectId && !workItem);
return (
<>
{sidebarCollapsed && <AppSidebarToggleButton />}
{sidebarCollapsed && shouldShowSidebarToggleButton && <AppSidebarToggleButton />}
<div className="w-full">{header}</div>
</>
);
+1
View File
@@ -0,0 +1 @@
export const isSidebarToggleVisible = () => true;
+2
View File
@@ -0,0 +1,2 @@
export * from "./helper";
export * from "./sidebar-workspace-menu";
@@ -0,0 +1,3 @@
export function DesktopSidebarWorkspaceMenu() {
return null;
}
+3
View File
@@ -29,6 +29,8 @@ import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const IssuesHeader = observer(function IssuesHeader() {
// router
@@ -61,6 +63,7 @@ export const IssuesHeader = observer(function IssuesHeader() {
<Header.LeftItem>
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"} className="flex-grow-0">
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
@@ -20,7 +20,7 @@ export const TopNavigationRoot = observer(() => {
>
{/* Workspace Menu */}
<div className="shrink-0 flex-1">
<WorkspaceMenuRoot />
<WorkspaceMenuRoot variant="top-navigation" />
</div>
{/* Power K Search */}
<div className="shrink-0">
@@ -14,6 +14,8 @@ import { InboxIssueCreateModalRoot } from "@/components/inbox/modals/create-moda
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
// states
@@ -38,6 +40,7 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
<Header.LeftItem>
<div className="flex items-center gap-4 flex-grow">
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
@@ -12,7 +12,7 @@ export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper
children: React.ReactNode;
}) {
return (
<div className="flex flex-col relative size-full overflow-hidden bg-custom-background-90 rounded-lg transition-all ease-in-out duration-300">
<div className="flex flex-col relative size-full overflow-hidden bg-custom-background-90 transition-all ease-in-out duration-300">
<TopNavigationRoot />
<div className="relative flex size-full overflow-hidden">
<AppRailRoot />
@@ -112,11 +112,10 @@ export class IssueActivityStore implements IIssueActivityStore {
comments.forEach((commentId) => {
const comment = currentStore.comment.getCommentById(commentId);
if (!comment) return;
const commentTimestamp = comment.edited_at ?? comment.updated_at ?? comment.created_at;
activityComments.push({
id: comment.id,
activity_type: EActivityFilterType.COMMENT,
created_at: commentTimestamp,
created_at: comment.created_at,
});
});
@@ -0,0 +1,256 @@
"use client";
import type { ReactNode } from "react";
import { useRef, useState } from "react";
import { observer } from "mobx-react";
import { usePopper } from "react-popper";
import { Search } from "lucide-react";
import { Combobox } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { IntakeStateGroupIcon, ChevronDownIcon } from "@plane/propel/icons";
import type { IIntakeState } from "@plane/types";
import { ComboDropDown, Spinner } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { DropdownButton } from "@/components/dropdowns/buttons";
import { BUTTON_VARIANTS_WITH_TEXT } from "@/components/dropdowns/constants";
import type { TDropdownProps } from "@/components/dropdowns/types";
// hooks
import { useDropdown } from "@/hooks/use-dropdown";
// plane web imports
import { StateOption } from "@/plane-web/components/workflow";
export type TWorkItemStateDropdownBaseProps = TDropdownProps & {
alwaysAllowStateChange?: boolean;
button?: ReactNode;
dropdownArrow?: boolean;
dropdownArrowClassName?: string;
filterAvailableStateIds?: boolean;
getStateById: (stateId: string | null | undefined) => IIntakeState | undefined;
iconSize?: string;
isForWorkItemCreation?: boolean;
isInitializing?: boolean;
onChange: (val: string) => void;
onClose?: () => void;
onDropdownOpen?: () => void;
projectId: string | undefined;
renderByDefault?: boolean;
showDefaultState?: boolean;
stateIds: string[];
value: string | undefined | null;
};
export const WorkItemStateDropdownBase: React.FC<TWorkItemStateDropdownBaseProps> = observer((props) => {
const {
button,
buttonClassName,
buttonContainerClassName,
buttonVariant,
className = "",
disabled = false,
dropdownArrow = false,
dropdownArrowClassName = "",
getStateById,
hideIcon = false,
iconSize = "size-4",
isInitializing = false,
onChange,
onClose,
onDropdownOpen,
placement,
renderByDefault = true,
showDefaultState = true,
showTooltip = false,
stateIds,
tabIndex,
value,
} = props;
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// store hooks
const { t } = useTranslation();
const statesList = stateIds.map((stateId) => getStateById(stateId)).filter((state) => !!state);
const defaultState = statesList?.find((state) => state?.default) || statesList[0];
const stateValue = !!value ? value : showDefaultState ? defaultState?.id : undefined;
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
// dropdown init
const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({
dropdownRef,
inputRef,
isOpen,
onClose,
onOpen: onDropdownOpen,
query,
setIsOpen,
setQuery,
});
// derived values
const options = statesList?.map((state) => ({
value: state?.id,
query: `${state?.name}`,
content: (
<div className="flex items-center gap-2">
<IntakeStateGroupIcon
stateGroup={state?.group ?? "triage"}
color={state?.color}
className={cn("flex-shrink-0", iconSize)}
/>
<span className="flex-grow truncate text-left">{state?.name}</span>
</div>
),
}));
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
const selectedState = stateValue ? getStateById(stateValue) : undefined;
const dropdownOnChange = (val: string) => {
onChange(val);
handleClose();
};
const comboButton = (
<>
{button ? (
<button
ref={setReferenceElement}
type="button"
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick}
disabled={disabled}
tabIndex={tabIndex}
>
{button}
</button>
) : (
<button
tabIndex={tabIndex}
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,
},
buttonContainerClassName
)}
onClick={handleOnClick}
disabled={disabled}
>
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading={t("state")}
tooltipContent={selectedState?.name ?? t("state")}
showTooltip={showTooltip}
variant={buttonVariant}
renderToolTipByDefault={renderByDefault}
>
{isInitializing ? (
<Spinner className="h-3.5 w-3.5" />
) : (
<>
{!hideIcon && (
<IntakeStateGroupIcon
stateGroup={selectedState?.group ?? "triage"}
color={selectedState?.color ?? "rgba(var(--color-text-300))"}
className={cn("flex-shrink-0", iconSize)}
/>
)}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate text-left">{selectedState?.name ?? t("state")}</span>
)}
{dropdownArrow && (
<ChevronDownIcon
className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)}
aria-hidden="true"
/>
)}
</>
)}
</DropdownButton>
</button>
)}
</>
);
return (
<ComboDropDown
as="div"
ref={dropdownRef}
className={cn("h-full", className)}
value={stateValue}
onChange={dropdownOnChange}
disabled={disabled}
onKeyDown={handleKeyDown}
button={comboButton}
renderByDefault={renderByDefault}
>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("common.search.label")}
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<StateOption
{...props}
key={option.value}
option={option}
selectedValue={value}
className="flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5"
/>
))
) : (
<p className="px-1.5 py-1 italic text-custom-text-400">{t("no_matching_results")}</p>
)
) : (
<p className="px-1.5 py-1 italic text-custom-text-400">{t("loading")}</p>
)}
</div>
</div>
</Combobox.Options>
)}
</ComboDropDown>
);
});
@@ -0,0 +1,48 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import { useProjectState } from "@/hooks/store/use-project-state";
// local imports
import type { TWorkItemStateDropdownBaseProps } from "./base";
import { WorkItemStateDropdownBase } from "./base";
type TWorkItemStateDropdownProps = Omit<
TWorkItemStateDropdownBaseProps,
"stateIds" | "getStateById" | "onDropdownOpen" | "isInitializing"
> & {
stateIds?: string[];
};
export const IntakeStateDropdown: React.FC<TWorkItemStateDropdownProps> = observer((props) => {
const { projectId, stateIds: propsStateIds } = props;
// router params
const { workspaceSlug } = useParams();
// states
const [stateLoader, setStateLoader] = useState(false);
// store hooks
const { fetchProjectIntakeState, getProjectIntakeStateIds, getIntakeStateById } = useProjectState();
// derived values
const stateIds = propsStateIds ?? getProjectIntakeStateIds(projectId);
// fetch states if not provided
const onDropdownOpen = async () => {
if ((stateIds === undefined || stateIds.length === 0) && workspaceSlug && projectId) {
setStateLoader(true);
await fetchProjectIntakeState(workspaceSlug.toString(), projectId);
setStateLoader(false);
}
};
return (
<WorkItemStateDropdownBase
{...props}
getStateById={getIntakeStateById}
isInitializing={stateLoader}
stateIds={stateIds ?? []}
onDropdownOpen={onDropdownOpen}
/>
);
});
@@ -14,6 +14,7 @@ import { ControlLink } from "@plane/ui";
import { getDate, renderFormattedPayloadDate, generateWorkItemLink } from "@plane/utils";
// components
import { DateDropdown } from "@/components/dropdowns/date";
import { IntakeStateDropdown } from "@/components/dropdowns/intake-state/dropdown";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
@@ -30,10 +31,12 @@ type Props = {
issueOperations: TIssueOperations;
isEditable: boolean;
duplicateIssueDetails: TInboxDuplicateIssueDetails | undefined;
isIntakeAccepted: boolean;
};
export const InboxIssueContentProperties = observer(function InboxIssueContentProperties(props: Props) {
const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails } = props;
const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails, isIntakeAccepted } =
props;
const router = useAppRouter();
// store hooks
@@ -50,6 +53,7 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
projectIdentifier: currentProjectDetails?.identifier,
sequenceId: duplicateIssueDetails?.sequence_id,
});
const DropdownComponent = isIntakeAccepted ? StateDropdown : IntakeStateDropdown;
return (
<div className="flex w-full flex-col divide-y-2 divide-custom-border-200">
@@ -57,20 +61,18 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
<h5 className="text-sm font-medium my-4">Properties</h5>
<div className={`divide-y-2 divide-custom-border-200 ${!isEditable ? "opacity-60" : ""}`}>
<div className="flex flex-col gap-3">
{/* State */}
{/* Intake State */}
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<StatePropertyIcon className="h-4 w-4 flex-shrink-0" />
<span>State</span>
</div>
{issue?.state_id && (
<StateDropdown
<DropdownComponent
value={issue?.state_id}
onChange={(val) =>
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { state_id: val })
}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
disabled={!isEditable}
disabled
buttonVariant="transparent-with-text"
className="w-3/5 flex-grow group"
buttonContainerClassName="w-full text-left"
@@ -6,7 +6,7 @@ import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import type { EditorRefApi } from "@plane/editor";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType, EInboxIssueSource } from "@plane/types";
import { EFileAssetType, EInboxIssueSource, EInboxIssueStatus } from "@plane/types";
import { getTextContent } from "@plane/utils";
// components
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
@@ -74,6 +74,7 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
// derived values
const issue = inboxIssue.issue;
const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined;
const isIntakeAccepted = inboxIssue.status === EInboxIssueStatus.ACCEPTED;
// debounced duplicate issues swr
const { duplicateIssues } = useDebouncedDuplicateIssues(
@@ -262,6 +263,7 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro
issueOperations={issueOperations}
isEditable={isEditable}
duplicateIssueDetails={inboxIssue?.duplicate_issue_detail}
isIntakeAccepted={isIntakeAccepted}
/>
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} isIntakeIssue />
@@ -53,10 +53,6 @@ export const InboxIssueFilterSelection = observer(function InboxIssueFilterSelec
<div className="py-2">
<FilterStatus searchQuery={filtersSearchQuery} />
</div>
{/* state */}
<div className="py-2">
<FilterState states={projectStates} searchQuery={filtersSearchQuery} />
</div>
{/* Priority */}
<div className="py-2">
<FilterPriority searchQuery={filtersSearchQuery} />
@@ -10,10 +10,10 @@ import { renderFormattedPayloadDate, getDate, getTabIndex } from "@plane/utils";
import { CycleDropdown } from "@/components/dropdowns/cycle";
import { DateDropdown } from "@/components/dropdowns/date";
import { EstimateDropdown } from "@/components/dropdowns/estimate";
import { IntakeStateDropdown } from "@/components/dropdowns/intake-state/dropdown";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { ModuleDropdown } from "@/components/dropdowns/module/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
import { ParentIssuesListModal } from "@/components/issues/parent-issues-list-modal";
import { IssueLabelSelect } from "@/components/issues/select";
// helpers
@@ -50,9 +50,9 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
return (
<div className="relative flex flex-wrap gap-2 items-center">
{/* state */}
{/* intake state */}
<div className="h-7">
<StateDropdown
<IntakeStateDropdown
value={data?.state_id}
onChange={(stateId) => handleData("state_id", stateId)}
projectId={projectId}
+6 -2
View File
@@ -1,4 +1,3 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react";
@@ -30,12 +29,17 @@ export const InboxIssueRoot = observer(function InboxIssueRoot(props: TInboxIssu
// plane hooks
const { t } = useTranslation();
// hooks
const { loader, error, currentTab, handleCurrentTab, fetchInboxIssues } = useProjectInbox();
const { loader, error, currentTab, currentInboxProjectId, handleCurrentTab, fetchInboxIssues } = useProjectInbox();
useEffect(() => {
if (!inboxAccessible || !workspaceSlug || !projectId) return;
// Check if project has changed
const hasProjectChanged = currentInboxProjectId && currentInboxProjectId !== projectId;
if (navigationTab && navigationTab !== currentTab) {
handleCurrentTab(workspaceSlug, projectId, navigationTab);
} else if (hasProjectChanged) {
handleCurrentTab(workspaceSlug, projectId, EInboxIssueCurrentTab.OPEN);
} else {
fetchInboxIssues(
workspaceSlug.toString(),
@@ -1,4 +1,3 @@
import type { FC } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
@@ -138,6 +137,7 @@ export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarP
title={t("common_empty_state.search.title")}
description={t("common_empty_state.search.description")}
assetClassName="size-20"
rootClassName="px-page-x"
/>
) : currentTab === EInboxIssueCurrentTab.OPEN ? (
<EmptyStateDetailed
@@ -152,6 +152,7 @@ export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarP
variant: "primary",
},
]}
rootClassName="px-page-x"
/>
) : (
// TODO: Add translation
@@ -1,4 +1,3 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import type { E_SORT_ORDER, TActivityFilters } from "@plane/constants";
@@ -78,16 +77,19 @@ export const IssueActivityCommentRoot = observer(function IssueActivityCommentRo
/>
) : BASE_ACTIVITY_FILTER_TYPES.includes(activityComment.activity_type as EActivityFilterType) ? (
<IssueActivityItem
key={activityComment.id}
activityId={activityComment.id}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
/>
) : activityComment.activity_type === "ISSUE_ADDITIONAL_PROPERTIES_ACTIVITY" ? (
<IssueAdditionalPropertiesActivity
key={activityComment.id}
activityId={activityComment.id}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
/>
) : activityComment.activity_type === "WORKLOG" ? (
<IssueActivityWorklog
key={activityComment.id}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
@@ -8,6 +8,8 @@ import { cn } from "@plane/utils";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
// hooks
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
// plane web imports
import { DesktopSidebarWorkspaceMenu } from "@/plane-web/components/desktop";
// local imports
import { AppSidebarItemsRoot } from "./items-root";
@@ -39,6 +41,7 @@ export const AppRailRoot = observer(() => {
"gap-3": !showLabel,
})}
>
<DesktopSidebarWorkspaceMenu />
<AppSidebarItemsRoot showLabel={showLabel} />
<div className="border-t border-custom-sidebar-border-300 mx-2" />
<AppSidebarItem
@@ -167,7 +167,7 @@ export const TabNavigationRoot: FC<TTabNavigationRootProps> = observer((props) =
/>
{/* container for the tab navigation */}
<div className="flex items-center gap-3 overflow-hidden pl-1.5 size-full">
<div className="flex items-center gap-3 overflow-hidden size-full">
<div className="flex items-center gap-2 shrink-0">
<ProjectHeader project={project} />
<div className="shrink-0">
@@ -0,0 +1,35 @@
import { TriangleAlert } from "lucide-react";
import { cn } from "@plane/utils";
type Props = {
className?: string;
onDismiss?: () => void;
};
export const ContentLimitBanner: React.FC<Props> = ({ className, onDismiss }) => (
<div
className={cn(
"flex items-center gap-2 bg-custom-background-80 border-b border-custom-border-200 px-4 py-2.5 text-sm",
className
)}
>
<div className="flex items-center gap-2 text-custom-text-200 mx-auto">
<span className="text-amber-500">
<TriangleAlert />
</span>
<span className="font-medium">
Content limit reached and live sync is off. Create a new page or use nested pages to continue syncing.
</span>
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className="ml-auto text-custom-text-300 hover:text-custom-text-200"
aria-label="Dismiss content limit warning"
>
</button>
)}
</div>
);
@@ -1,11 +1,12 @@
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { observer } from "mobx-react";
// plane imports
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
import { CollaborativeDocumentEditorWithRef } from "@plane/editor";
import type {
CollaborationState,
EditorRefApi,
EditorTitleRefApi,
TAIMenuProps,
TDisplayConfig,
TFileHandler,
@@ -26,6 +27,7 @@ import { useUser } from "@/hooks/store/user";
import { usePageFilters } from "@/hooks/use-page-filters";
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
// plane web imports
import type { TCustomEventHandlers } from "@/hooks/use-realtime-page-events";
import { EditorAIMenu } from "@/plane-web/components/pages";
import type { TExtendedEditorExtensionsConfig } from "@/plane-web/hooks/pages";
import type { EPageStoreType } from "@/plane-web/hooks/store";
@@ -51,7 +53,6 @@ type Props = {
config: TEditorBodyConfig;
editorReady: boolean;
editorForwardRef: React.RefObject<EditorRefApi>;
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
handleEditorReady: (status: boolean) => void;
handleOpenNavigationPane: () => void;
handlers: TEditorBodyHandlers;
@@ -61,14 +62,16 @@ type Props = {
projectId?: string;
workspaceSlug: string;
storeType: EPageStoreType;
customRealtimeEventHandlers?: TCustomEventHandlers;
extendedEditorProps: TExtendedEditorExtensionsConfig;
isFetchingFallbackBinary?: boolean;
onCollaborationStateChange?: (state: CollaborationState) => void;
};
export const PageEditorBody = observer(function PageEditorBody(props: Props) {
const {
config,
editorForwardRef,
handleConnectionStatus,
handleEditorReady,
handleOpenNavigationPane,
handlers,
@@ -79,7 +82,11 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
projectId,
workspaceSlug,
extendedEditorProps,
isFetchingFallbackBinary,
onCollaborationStateChange,
} = props;
// refs
const titleEditorRef = useRef<EditorTitleRefApi>(null);
// store hooks
const { data: currentUser } = useUser();
const { getWorkspaceBySlug } = useWorkspace();
@@ -91,6 +98,7 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
isContentEditable,
updateTitle,
editor: { editorRef, updateAssetsList },
setSyncingStatus,
} = page;
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
// use editor mention
@@ -136,20 +144,35 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
[editorRef, workspaceId, workspaceSlug]
);
const handleServerConnect = useCallback(() => {
handleConnectionStatus(false);
}, [handleConnectionStatus]);
const handleServerError = useCallback(() => {
handleConnectionStatus(true);
}, [handleConnectionStatus]);
// Set syncing status when page changes and reset collaboration state
useEffect(() => {
setSyncingStatus("syncing");
onCollaborationStateChange?.({
stage: { kind: "connecting" },
isServerSynced: false,
isServerDisconnected: false,
});
}, [pageId, setSyncingStatus, onCollaborationStateChange]);
const serverHandler: TServerHandler = useMemo(
() => ({
onConnect: handleServerConnect,
onServerError: handleServerError,
onStateChange: (state) => {
// Pass full state to parent
onCollaborationStateChange?.(state);
// Map collaboration stage to UI syncing status
// Stage → UI mapping: disconnected → error | synced → synced | all others → syncing
if (state.stage.kind === "disconnected") {
setSyncingStatus("error");
} else if (state.stage.kind === "synced") {
setSyncingStatus("synced");
} else {
// initial, connecting, awaiting-sync, reconnecting → show as syncing
setSyncingStatus("syncing");
}
},
}),
[handleServerConnect, handleServerError]
[setSyncingStatus, onCollaborationStateChange]
);
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
@@ -194,7 +217,9 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
}
);
if (pageId === undefined || !realtimeConfig) return <PageContentLoader className={blockWidthClassName} />;
const isPageLoading = pageId === undefined || !realtimeConfig;
if (isPageLoading) return <PageContentLoader className={blockWidthClassName} />;
return (
<Row
@@ -225,12 +250,6 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
<div className="page-header-container group/page-header">
<div className={blockWidthClassName}>
<PageEditorHeaderRoot page={page} projectId={projectId} />
<PageEditorTitle
editorRef={editorRef}
readOnly={!isContentEditable}
title={pageTitle}
updateTitle={updateTitle}
/>
</div>
</div>
<CollaborativeDocumentEditorWithRef
@@ -239,6 +258,7 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
fileHandler={config.fileHandler}
handleEditorReady={handleEditorReady}
ref={editorForwardRef}
titleRef={titleEditorRef}
containerClassName="h-full p-0 pb-64"
displayConfig={displayConfig}
getEditorMetaData={getEditorMetaData}
@@ -261,6 +281,7 @@ export const PageEditorBody = observer(function PageEditorBody(props: Props) {
}}
onAssetChange={updateAssetsList}
extendedEditorProps={extendedEditorProps}
isFetchingFallbackBinary={isFetchingFallbackBinary}
/>
</div>
</Row>
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import type { CollaborationState, EditorRefApi } from "@plane/editor";
import type { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types";
// hooks
import { useAppRouter } from "@/hooks/use-app-router";
import { usePageFallback } from "@/hooks/use-page-fallback";
// plane web import
import type { PageUpdateHandler, TCustomEventHandlers } from "@/hooks/use-realtime-page-events";
import { PageModals } from "@/plane-web/components/pages";
import { usePagesPaneExtensions, useExtendedEditorProps } from "@/plane-web/hooks/pages";
import type { EPageStoreType } from "@/plane-web/hooks/store";
@@ -16,6 +16,7 @@ import type { TPageInstance } from "@/store/pages/base-page";
import { PageNavigationPaneRoot } from "../navigation-pane";
import { PageVersionsOverlay } from "../version";
import { PagesVersionEditor } from "../version/editor";
import { ContentLimitBanner } from "./content-limit-banner";
import { PageEditorBody } from "./editor-body";
import type { TEditorBodyConfig, TEditorBodyHandlers } from "./editor-body";
import { PageEditorToolbarRoot } from "./toolbar";
@@ -23,7 +24,7 @@ import { PageEditorToolbarRoot } from "./toolbar";
export type TPageRootHandlers = {
create: (payload: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
fetchDescriptionBinary: () => Promise<any>;
fetchDescriptionBinary: () => Promise<ArrayBuffer>;
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
restoreVersion: (pageId: string, versionId: string) => Promise<void>;
updateDescription: (document: TDocumentPayload) => Promise<void>;
@@ -39,27 +40,36 @@ type TPageRootProps = {
webhookConnectionParams: TWebhookConnectionQueryParams;
projectId?: string;
workspaceSlug: string;
customRealtimeEventHandlers?: TCustomEventHandlers;
};
export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
const { config, handlers, page, projectId, storeType, webhookConnectionParams, workspaceSlug } = props;
export const PageRoot = observer((props: TPageRootProps) => {
const {
config,
handlers,
page,
projectId,
storeType,
webhookConnectionParams,
workspaceSlug,
customRealtimeEventHandlers,
} = props;
// states
const [editorReady, setEditorReady] = useState(false);
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
const [collaborationState, setCollaborationState] = useState<CollaborationState | null>(null);
const [showContentTooLargeBanner, setShowContentTooLargeBanner] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
// router
const router = useAppRouter();
// derived values
const {
isContentEditable,
editor: { setEditorRef },
} = page;
// page fallback
usePageFallback({
const { isFetchingFallbackBinary } = usePageFallback({
editorRef,
fetchPageDescription: handlers.fetchDescriptionBinary,
hasConnectionFailed,
collaborationState,
updatePageDescription: handlers.updateDescription,
});
@@ -91,6 +101,24 @@ export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
editorRef,
});
// Type-safe error handler for content too large errors
const errorHandler: PageUpdateHandler<"error"> = (params) => {
const { data } = params;
// Check if it's content too large error
if (data.error_code === "content_too_large") {
setShowContentTooLargeBanner(true);
}
// Call original error handler if exists
customRealtimeEventHandlers?.error?.(params);
};
const mergedCustomEventHandlers: TCustomEventHandlers = {
...customRealtimeEventHandlers,
error: errorHandler,
};
// Get extended editor extensions configuration
const extendedEditorProps = useExtendedEditorProps({
workspaceSlug,
@@ -134,11 +162,12 @@ export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
isNavigationPaneOpen={isNavigationPaneOpen}
page={page}
/>
{showContentTooLargeBanner && <ContentLimitBanner className="px-page-x" />}
<PageEditorBody
config={config}
customRealtimeEventHandlers={mergedCustomEventHandlers}
editorReady={editorReady}
editorForwardRef={editorRef}
handleConnectionStatus={setHasConnectionFailed}
handleEditorReady={handleEditorReady}
handleOpenNavigationPane={handleOpenNavigationPane}
handlers={handlers}
@@ -149,6 +178,8 @@ export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug}
extendedEditorProps={extendedEditorProps}
isFetchingFallbackBinary={isFetchingFallbackBinary}
onCollaborationStateChange={setCollaborationState}
/>
</div>
<PageNavigationPaneRoot
@@ -0,0 +1,72 @@
import { useState, useEffect } from "react";
import { CloudOff } from "lucide-react";
import { Tooltip } from "@plane/ui";
type Props = {
syncStatus: "syncing" | "synced" | "error";
};
export const PageSyncingBadge = ({ syncStatus }: Props) => {
const [prevSyncStatus, setPrevSyncStatus] = useState<"syncing" | "synced" | "error" | null>(null);
const [isVisible, setIsVisible] = useState(syncStatus !== "synced");
useEffect(() => {
// Only handle transitions when there's a change
if (prevSyncStatus !== syncStatus) {
if (syncStatus === "synced") {
// Delay hiding to allow exit animation to complete
setTimeout(() => {
setIsVisible(false);
}, 300); // match animation duration
} else {
setIsVisible(true);
}
setPrevSyncStatus(syncStatus);
}
}, [syncStatus, prevSyncStatus]);
if (!isVisible || syncStatus === "synced") return null;
const badgeContent = {
syncing: {
label: "Syncing...",
tooltipHeading: "Syncing...",
tooltipContent: "Your changes are being synced with the server. You can continue making changes.",
bgColor: "bg-custom-primary-100/20",
textColor: "text-custom-primary-100",
pulseColor: "bg-custom-primary-100",
pulseBgColor: "bg-custom-primary-100/30",
icon: null,
},
error: {
label: "Connection lost",
tooltipHeading: "Connection lost",
tooltipContent:
"We're having trouble connecting to the websocket server. Your changes will be synced and saved every 10 seconds.",
bgColor: "bg-red-500/20",
textColor: "text-red-500",
icon: <CloudOff className="size-3" />,
},
};
// This way we guarantee badgeContent is defined
const content = badgeContent[syncStatus];
return (
<Tooltip tooltipHeading={content.tooltipHeading} tooltipContent={content.tooltipContent}>
<div
className={`flex-shrink-0 h-6 flex items-center gap-1.5 px-2 rounded ${content.textColor} ${content.bgColor} animate-quickFadeIn`}
>
{syncStatus === "syncing" ? (
<div className="relative flex-shrink-0">
<div className="absolute -inset-0.5 rounded-full bg-custom-primary-100/30 animate-ping" />
<div className="relative h-1.5 w-1.5 rounded-full bg-custom-primary-100" />
</div>
) : (
content.icon
)}
<span className="text-xs font-medium">{content.label}</span>
</div>
</Tooltip>
);
};
@@ -6,7 +6,6 @@ import { useCycle } from "@/hooks/store/use-cycle";
import { useModule } from "@/hooks/store/use-module";
// plane web imports
import { useExtendedContextIndicator } from "@/plane-web/components/command-palette/power-k/hooks/use-extended-context-indicator";
import type { TPowerKContextTypeExtended } from "@/plane-web/components/command-palette/power-k/types";
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
// local imports
import type { TPowerKContextType } from "../core/types";
@@ -25,13 +24,13 @@ export const useContextIndicator = (args: TArgs): string | null => {
const { getPageById } = usePageStore(EPageStoreType.PROJECT);
// extended context indicator
const extendedIndicator = useExtendedContextIndicator({
activeContext: activeContext as TPowerKContextTypeExtended,
activeContext,
});
let indicator: string | undefined | null = null;
switch (activeContext) {
case "work-item": {
indicator = workItemIdentifier.toString();
indicator = workItemIdentifier ? workItemIdentifier.toString() : null;
break;
}
case "cycle": {
@@ -411,6 +411,7 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
}}
error={Boolean(errors.timezone)}
buttonClassName="border-none"
disabled={!isAdmin}
/>
</>
)}
@@ -2,11 +2,13 @@ import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { isSidebarToggleVisible } from "@/plane-web/components/desktop";
export const AppSidebarToggleButton = observer(function AppSidebarToggleButton() {
// store hooks
const { toggleSidebar, sidebarPeek, toggleSidebarPeek } = useAppTheme();
if (!isSidebarToggleVisible()) return null;
return (
<button
className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90"
@@ -1,10 +0,0 @@
// hooks
import { UserMenuRoot } from "./user-menu-root";
import { WorkspaceMenuRoot } from "./workspace-menu-root";
export const SidebarDropdown = () => (
<div className="flex items-center justify-center gap-1.5 w-full">
<WorkspaceMenuRoot />
<UserMenuRoot />
</div>
);
@@ -25,11 +25,11 @@ import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
type WorkspaceMenuRootProps = {
renderLogoOnly?: boolean;
variant: "sidebar" | "top-navigation";
};
export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: WorkspaceMenuRootProps) {
const { renderLogoOnly } = props;
const { variant } = props;
// store hooks
const { toggleSidebar, toggleAnySidebarDropdown } = useAppTheme();
const { data: currentUser } = useUser();
@@ -72,8 +72,8 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<Menu
as="div"
className={cn("relative h-full flex max-w-48 w-fit whitespace-nowrap truncate", {
"justify-center text-center": renderLogoOnly,
"flex-grow justify-stretch text-left truncate": !renderLogoOnly,
"w-full justify-center text-center": variant === "sidebar",
"flex-grow justify-stretch text-left truncate": variant === "top-navigation",
})}
>
{({ open, close }: { open: boolean; close: () => void }) => {
@@ -84,9 +84,9 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
return (
<>
{renderLogoOnly ? (
{variant === "sidebar" && (
<Menu.Button
className={cn("flex items-center justify-center size-8 rounded", {
className={cn("flex w-full items-center justify-center size-8 rounded-md", {
"bg-custom-sidebar-background-80": open,
})}
>
@@ -97,26 +97,29 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<WorkspaceLogo
logo={activeWorkspace?.logo_url}
name={activeWorkspace?.name}
classNames="size-8 rounded-md"
classNames="size-8 rounded-md border border-custom-border-200"
/>
),
}}
/>
</Menu.Button>
) : (
)}
{variant === "top-navigation" && (
<Menu.Button
className={cn(
"group/menu-button flex items-center gap-1 p-1 truncate rounded text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:outline-none ",
"group/menu-button flex items-center gap-1 p-1 truncate rounded text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:outline-none justify-between flex-grow",
{
"justify-center text-center": renderLogoOnly,
"justify-between flex-grow": !renderLogoOnly,
"bg-custom-sidebar-background-80": open,
}
)}
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
>
<div className="flex-grow flex items-center gap-2 truncate">
<WorkspaceLogo logo={activeWorkspace?.logo_url} name={activeWorkspace?.name} />
<WorkspaceLogo
logo={activeWorkspace?.logo_url}
name={activeWorkspace?.name}
classNames="border border-custom-border-200 size-7"
/>
<h4 className="truncate text-base font-medium text-custom-text-100">
{activeWorkspace?.name ?? t("loading")}
</h4>
@@ -128,7 +131,6 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
/>
</Menu.Button>
)}
<Transition
as={Fragment}
enter="transition ease-out duration-100"
@@ -139,7 +141,15 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items as={Fragment}>
<div className="fixed top-10 left-4 z-[21] mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
<div
className={cn(
"fixed z-[21] mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none",
{
"top-11 left-14": variant === "sidebar",
"top-10 left-4": variant === "top-navigation",
}
)}
>
<div className="overflow-x-hidden vertical-scrollbar scrollbar-sm flex max-h-96 flex-col items-start justify-start overflow-y-scroll">
<span className="rounded-md text-left px-4 sticky top-0 z-[21] h-full w-full bg-custom-sidebar-background-100 pb-1 pt-3 text-sm font-medium text-custom-text-400 truncate flex-shrink-0">
{currentUser?.email}
+3
View File
@@ -163,6 +163,9 @@ export const PROJECT_MEMBERS = (workspaceSlug: string, projectId: string) =>
export const PROJECT_STATES = (workspaceSlug: string, projectId: string) =>
`PROJECT_STATES_${projectId.toString().toUpperCase()}`;
export const PROJECT_INTAKE_STATE = (workspaceSlug: string, projectId: string) =>
`PROJECT_INTAKE_STATE_${projectId.toString().toUpperCase()}`;
export const PROJECT_ESTIMATES = (workspaceSlug: string, projectId: string) =>
`PROJECT_ESTIMATES_${projectId.toString().toUpperCase()}`;
+37 -6
View File
@@ -1,7 +1,9 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { EditorRefApi, CollaborationState } from "@plane/editor";
// plane editor
import { convertBinaryDataToBase64String, getBinaryDataFromDocumentEditorHTMLString } from "@plane/editor";
import type { EditorRefApi } from "@plane/editor";
// plane propel
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
// plane types
import type { TDocumentPayload } from "@plane/types";
// hooks
@@ -10,19 +12,37 @@ import useAutoSave from "@/hooks/use-auto-save";
type TArgs = {
editorRef: React.RefObject<EditorRefApi>;
fetchPageDescription: () => Promise<ArrayBuffer>;
hasConnectionFailed: boolean;
collaborationState: CollaborationState | null;
updatePageDescription: (data: TDocumentPayload) => Promise<void>;
};
export const usePageFallback = (args: TArgs) => {
const { editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription } = args;
const { editorRef, fetchPageDescription, collaborationState, updatePageDescription } = args;
const hasShownFallbackToast = useRef(false);
const [isFetchingFallbackBinary, setIsFetchingFallbackBinary] = useState(false);
// Derive connection failure from collaboration state
const hasConnectionFailed = collaborationState?.stage.kind === "disconnected";
const handleUpdateDescription = useCallback(async () => {
if (!hasConnectionFailed) return;
const editor = editorRef.current;
if (!editor) return;
// Show toast notification when fallback mechanism kicks in (only once)
if (!hasShownFallbackToast.current) {
setToast({
type: TOAST_TYPE.WARNING,
title: "Connection lost",
message: "Your changes are being saved using backup mechanism. ",
});
hasShownFallbackToast.current = true;
}
try {
setIsFetchingFallbackBinary(true);
const latestEncodedDescription = await fetchPageDescription();
let latestDecodedDescription: Uint8Array;
if (latestEncodedDescription && latestEncodedDescription.byteLength > 0) {
@@ -41,16 +61,27 @@ export const usePageFallback = (args: TArgs) => {
description_html: html,
description: json,
});
} catch (error) {
console.error("Error in updating description using fallback logic:", error);
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error",
message: `Failed to update description using backup mechanism, ${error?.message}`,
});
} finally {
setIsFetchingFallbackBinary(false);
}
}, [editorRef, fetchPageDescription, hasConnectionFailed, updatePageDescription]);
useEffect(() => {
if (hasConnectionFailed) {
handleUpdateDescription();
} else {
// Reset toast flag when connection is restored
hasShownFallbackToast.current = false;
}
}, [handleUpdateDescription, hasConnectionFailed]);
useAutoSave(handleUpdateDescription);
return { isFetchingFallbackBinary };
};
-5
View File
@@ -59,11 +59,6 @@ const useTimezone = () => {
query: "utc, coordinated universal time",
content: "UTC",
},
{
value: "Universal",
query: "universal, coordinated universal time",
content: "Universal",
},
];
const selectedTimezone = (value: string | undefined) => options.find((option) => option.value === value)?.content;
@@ -21,6 +21,7 @@ import {
PROJECT_ALL_CYCLES,
PROJECT_MODULES,
PROJECT_VIEWS,
PROJECT_INTAKE_STATE,
} from "@/constants/fetch-keys";
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
@@ -58,8 +59,8 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
const {
project: { fetchProjectMembers, fetchProjectMemberPreferences },
} = useMember();
const { fetchProjectStates, fetchProjectIntakeState } = useProjectState();
const { data: currentUserData } = useUser();
const { fetchProjectStates } = useProjectState();
const { fetchProjectLabels } = useLabel();
const { getProjectEstimates } = useProjectEstimates();
@@ -112,6 +113,12 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug, projectId) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project intake state
useSWR(
workspaceSlug && projectId ? PROJECT_INTAKE_STATE(workspaceSlug, projectId) : null,
workspaceSlug && projectId ? () => fetchProjectIntakeState(workspaceSlug, projectId) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project estimates
useSWR(
workspaceSlug && projectId ? PROJECT_ESTIMATES(workspaceSlug, projectId) : null,
@@ -1,6 +1,6 @@
// services
import { API_BASE_URL } from "@plane/constants";
import type { IState } from "@plane/types";
import type { IIntakeState, IState } from "@plane/types";
import { APIService } from "@/services/api.service";
// helpers
// types
@@ -34,6 +34,14 @@ export class ProjectStateService extends APIService {
});
}
async getIntakeState(workspaceSlug: string, projectId: string): Promise<IIntakeState> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/intake-state/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getState(workspaceSlug: string, projectId: string, stateId: string): Promise<any> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/states/${stateId}/`)
.then((response) => response?.data)
@@ -125,18 +125,16 @@ export class ProjectInboxStore implements IProjectInboxStore {
* @description computed project inbox filters
*/
get inboxFilters() {
const { projectId } = this.store.router;
if (!projectId) return {} as TInboxIssueFilter;
return this.filtersMap?.[projectId];
if (!this.currentInboxProjectId) return {} as TInboxIssueFilter;
return this.filtersMap?.[this.currentInboxProjectId];
}
/**
* @description computed project inbox sorting
*/
get inboxSorting() {
const { projectId } = this.store.router;
if (!projectId) return {} as TInboxIssueSorting;
return this.sortingMap?.[projectId];
if (!this.currentInboxProjectId) return {} as TInboxIssueSorting;
return this.sortingMap?.[this.currentInboxProjectId];
}
get getAppliedFiltersCount() {
@@ -274,7 +272,8 @@ export class ProjectInboxStore implements IProjectInboxStore {
};
handleInboxIssueFilters = <T extends keyof TInboxIssueFilter>(key: T, value: TInboxIssueFilter[T]) => {
const { workspaceSlug, projectId } = this.store.router;
const { workspaceSlug } = this.store.router;
const projectId = this.currentInboxProjectId;
if (workspaceSlug && projectId) {
runInAction(() => {
set(this.filtersMap, [projectId, key], value);
@@ -285,7 +284,8 @@ export class ProjectInboxStore implements IProjectInboxStore {
};
handleInboxIssueSorting = <T extends keyof TInboxIssueSorting>(key: T, value: TInboxIssueSorting[T]) => {
const { workspaceSlug, projectId } = this.store.router;
const { workspaceSlug } = this.store.router;
const projectId = this.currentInboxProjectId;
if (workspaceSlug && projectId) {
runInAction(() => {
set(this.sortingMap, [projectId, key], value);
@@ -469,8 +469,9 @@ export class ProjectInboxStore implements IProjectInboxStore {
);
});
return inboxIssueResponse;
} catch {
} catch (error) {
console.error("Error creating the intake issue");
throw error;
}
};
+10
View File
@@ -13,6 +13,7 @@ import { PageEditorInstance } from "./page-editor-info";
export type TBasePage = TPage & {
// observables
isSubmitting: TNameDescriptionLoader;
isSyncingWithServer: "syncing" | "synced" | "error";
// computed
asJSON: TPage | undefined;
isCurrentUserOwner: boolean;
@@ -35,6 +36,7 @@ export type TBasePage = TPage & {
removePageFromFavorites: () => Promise<void>;
duplicate: () => Promise<TPage | undefined>;
mutateProperties: (data: Partial<TPage>, shouldUpdateName?: boolean) => void;
setSyncingStatus: (status: "syncing" | "synced" | "error") => void;
// sub-store
editor: PageEditorInstance;
};
@@ -73,6 +75,7 @@ export type TPageInstance = TBasePage &
export class BasePage extends ExtendedBasePage implements TBasePage {
// loaders
isSubmitting: TNameDescriptionLoader = "saved";
isSyncingWithServer: "syncing" | "synced" | "error" = "syncing";
// page properties
id: string | undefined;
name: string | undefined;
@@ -155,6 +158,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
created_at: observable.ref,
updated_at: observable.ref,
deleted_at: observable.ref,
isSyncingWithServer: observable.ref,
// helpers
oldName: observable.ref,
setIsSubmitting: action,
@@ -535,4 +539,10 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
set(this, key, value);
});
};
setSyncingStatus = (status: "syncing" | "synced" | "error") => {
runInAction(() => {
this.isSyncingWithServer = status;
});
};
}
+58 -1
View File
@@ -3,7 +3,7 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"
import { computedFn } from "mobx-utils";
// plane imports
import { STATE_GROUPS } from "@plane/constants";
import type { IState } from "@plane/types";
import type { IIntakeState, IState } from "@plane/types";
// helpers
import { sortStates } from "@plane/utils";
// plane web
@@ -13,19 +13,25 @@ import type { RootStore } from "@/plane-web/store/root.store";
export interface IStateStore {
//Loaders
fetchedMap: Record<string, boolean>;
fetchedIntakeMap: Record<string, boolean>;
// observables
stateMap: Record<string, IState>;
intakeStateMap: Record<string, IIntakeState>;
// computed
workspaceStates: IState[] | undefined;
projectStates: IState[] | undefined;
groupedProjectStates: Record<string, IState[]> | undefined;
// computed actions
getStateById: (stateId: string | null | undefined) => IState | undefined;
getIntakeStateById: (intakeStateId: string | null | undefined) => IIntakeState | undefined;
getProjectStates: (projectId: string | null | undefined) => IState[] | undefined;
getProjectIntakeState: (projectId: string | null | undefined) => IIntakeState | undefined;
getProjectStateIds: (projectId: string | null | undefined) => string[] | undefined;
getProjectIntakeStateIds: (projectId: string | null | undefined) => string[] | undefined;
getProjectDefaultStateId: (projectId: string | null | undefined) => string | undefined;
// fetch actions
fetchProjectStates: (workspaceSlug: string, projectId: string) => Promise<IState[]>;
fetchProjectIntakeState: (workspaceSlug: string, projectId: string) => Promise<IIntakeState>;
fetchWorkspaceStates: (workspaceSlug: string) => Promise<IState[]>;
// crud actions
createState: (workspaceSlug: string, projectId: string, data: Partial<IState>) => Promise<IState>;
@@ -49,8 +55,10 @@ export interface IStateStore {
export class StateStore implements IStateStore {
stateMap: Record<string, IState> = {};
intakeStateMap: Record<string, IIntakeState> = {};
//loaders
fetchedMap: Record<string, boolean> = {};
fetchedIntakeMap: Record<string, boolean> = {};
rootStore: RootStore;
router;
stateService: ProjectStateService;
@@ -59,12 +67,15 @@ export class StateStore implements IStateStore {
makeObservable(this, {
// observables
stateMap: observable,
intakeStateMap: observable,
fetchedMap: observable,
fetchedIntakeMap: observable,
// computed
projectStates: computed,
groupedProjectStates: computed,
// fetch action
fetchProjectStates: action,
fetchProjectIntakeState: action,
// CRUD actions
createState: action,
updateState: action,
@@ -127,6 +138,15 @@ export class StateStore implements IStateStore {
return this.stateMap[stateId] ?? undefined;
});
/**
* @description returns intake state details using intake state id
* @param intakeStateId
*/
getIntakeStateById = computedFn((intakeStateId: string | null | undefined) => {
if (!this.intakeStateMap || !intakeStateId) return;
return this.intakeStateMap[intakeStateId] ?? undefined;
});
/**
* Returns the stateMap belongs to a project by projectId
* @param projectId
@@ -138,6 +158,16 @@ export class StateStore implements IStateStore {
return sortStates(Object.values(this.stateMap).filter((state) => state.project_id === projectId));
});
/**
* Returns the intake state for a project by projectId
* @param projectId
* @returns IIntakeState | undefined
*/
getProjectIntakeState = computedFn((projectId: string | null | undefined) => {
if (!projectId || !this.fetchedIntakeMap[projectId]) return;
return Object.values(this.intakeStateMap).find((state) => state.project_id === projectId);
});
/**
* Returns the state ids for a project by projectId
* @param projectId
@@ -151,6 +181,18 @@ export class StateStore implements IStateStore {
return projectStates?.map((state) => state.id) ?? [];
});
/**
* Returns the intake state ids for a project by projectId
* @param projectId
* @returns string[]
*/
getProjectIntakeStateIds = computedFn((projectId: string | null | undefined) => {
const workspaceSlug = this.router.workspaceSlug;
if (!workspaceSlug || !projectId || !this.fetchedIntakeMap[projectId]) return undefined;
const projectIntakeState = this.getProjectIntakeState(projectId);
return projectIntakeState?.id ? [projectIntakeState.id] : [];
});
/**
* Returns the default state id for a project
* @param projectId
@@ -178,6 +220,21 @@ export class StateStore implements IStateStore {
return statesResponse;
};
/**
* fetches the intakeStateMap of a project
* @param workspaceSlug
* @param projectId
* @returns
*/
fetchProjectIntakeState = async (workspaceSlug: string, projectId: string) => {
const intakeStateResponse = await this.stateService.getIntakeState(workspaceSlug, projectId);
runInAction(() => {
set(this.intakeStateMap, [intakeStateResponse.id], intakeStateResponse);
set(this.fetchedIntakeMap, projectId, true);
});
return intakeStateResponse;
};
/**
* fetches the stateMap of all the states in workspace
* @param workspaceSlug
+3
View File
@@ -44,13 +44,16 @@
"@tiptap/extension-blockquote": "^2.22.3",
"@tiptap/extension-character-count": "^2.22.3",
"@tiptap/extension-collaboration": "^2.22.3",
"@tiptap/extension-document": "^3.2.0",
"@tiptap/extension-emoji": "^2.22.3",
"@tiptap/extension-heading": "^3.4.3",
"@tiptap/extension-image": "^2.22.3",
"@tiptap/extension-list-item": "^2.22.3",
"@tiptap/extension-mention": "^2.22.3",
"@tiptap/extension-placeholder": "^2.22.3",
"@tiptap/extension-task-item": "^2.22.3",
"@tiptap/extension-task-list": "^2.22.3",
"@tiptap/extension-text": "^3.2.0",
"@tiptap/extension-text-align": "^2.22.3",
"@tiptap/extension-text-style": "^2.22.3",
"@tiptap/extension-underline": "^2.22.3",
@@ -1,20 +1,21 @@
import React from "react";
import React, { useMemo } from "react";
// plane imports
import { cn } from "@plane/utils";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// contexts
import { CollaborationProvider, useCollaboration } from "@/contexts/collaboration-context";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// constants
import { DocumentEditorSideEffects } from "@/plane-editor/components/document-editor-side-effects";
// types
import type { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
import { DocumentEditorSideEffects } from "@/plane-editor/components/document-editor-side-effects";
function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
function CollaborativeDocumentEditorInner(props: ICollaborativeDocumentEditorProps) {
const {
aiHandler,
bubbleMenuEnabled = true,
@@ -41,15 +42,20 @@ function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
onEditorFocus,
onTransaction,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
extendedDocumentEditorProps,
titleRef,
updatePageProperties,
isFetchingFallbackBinary,
} = props;
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
// Get non-null provider from context
const { provider, state, actions } = useCollaboration();
// Editor initialization with guaranteed non-null provider
const { editor, titleEditor } = useCollaborativeEditor({
provider,
disabledExtensions,
editable,
editorClassName,
@@ -70,11 +76,10 @@ function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
onEditorFocus,
onTransaction,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
titleRef,
user,
extendedDocumentEditorProps,
actions,
});
const editorContainerClassNames = getEditorClassNames({
@@ -83,37 +88,72 @@ function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
containerClassName,
});
if (!editor) return null;
// Show loader ONLY when cache is known empty and server hasn't synced yet
const shouldShowSyncLoader = state.isCacheReady && !state.hasCachedContent && !state.isServerSynced;
const shouldWaitForFallbackBinary = isFetchingFallbackBinary && !state.hasCachedContent && state.isServerDisconnected;
const isLoading = shouldShowSyncLoader || shouldWaitForFallbackBinary;
// Gate content rendering on isDocReady to prevent empty editor flash
const showContentSkeleton = !state.isDocReady;
if (!editor || !titleEditor) return null;
return (
<>
<DocumentEditorSideEffects editor={editor} id={id} extendedEditorProps={extendedEditorProps} />
<PageRenderer
aiHandler={aiHandler}
bubbleMenuEnabled={bubbleMenuEnabled}
displayConfig={displayConfig}
documentLoaderClassName={documentLoaderClassName}
editor={editor}
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
extendedEditorProps={extendedEditorProps}
id={id}
isTouchDevice={!!isTouchDevice}
isLoading={!hasServerSynced && !hasServerConnectionFailed}
tabIndex={tabIndex}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
extendedDocumentEditorProps={extendedDocumentEditorProps}
/>
<div
className={cn(
"transition-opacity duration-200",
showContentSkeleton && !isLoading && "opacity-0 pointer-events-none"
)}
>
<DocumentEditorSideEffects editor={editor} id={id} extendedEditorProps={extendedEditorProps} />
<PageRenderer
aiHandler={aiHandler}
bubbleMenuEnabled={bubbleMenuEnabled}
displayConfig={displayConfig}
documentLoaderClassName={documentLoaderClassName}
disabledExtensions={disabledExtensions}
extendedDocumentEditorProps={extendedDocumentEditorProps}
editor={editor}
flaggedExtensions={flaggedExtensions}
titleEditor={titleEditor}
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
extendedEditorProps={extendedEditorProps}
id={id}
isLoading={isLoading}
isTouchDevice={!!isTouchDevice}
tabIndex={tabIndex}
provider={provider}
state={state}
/>
</div>
</>
);
}
const CollaborativeDocumentEditorWithRef = React.forwardRef(function CollaborativeDocumentEditorWithRef(
props: ICollaborativeDocumentEditorProps,
ref: React.ForwardedRef<EditorRefApi>
) {
return <CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />;
});
// Outer component that provides collaboration context
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
const { id, realtimeConfig, serverHandler, user } = props;
const token = useMemo(() => JSON.stringify(user), [user]);
return (
<CollaborationProvider
docId={id}
serverUrl={realtimeConfig.url}
authToken={token}
onStateChange={serverHandler?.onStateChange}
>
<CollaborativeDocumentEditorInner {...props} />
</CollaborationProvider>
);
};
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
(props, ref) => (
<CollaborativeDocumentEditor key={props.id} {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi>} />
)
);
CollaborativeDocumentEditorWithRef.displayName = "CollaborativeDocumentEditorWithRef";
@@ -1,10 +1,12 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Editor } from "@tiptap/react";
// plane imports
import { cn } from "@plane/utils";
// components
import { DocumentContentLoader, EditorContainer, EditorContentWrapper } from "@/components/editors";
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
import { BlockMenu, EditorBubbleMenu } from "@/components/menus";
// types
import type { TCollabValue } from "@/contexts";
import type {
ICollaborativeDocumentEditorPropsExtended,
IEditorProps,
@@ -20,6 +22,7 @@ type Props = {
displayConfig: TDisplayConfig;
documentLoaderClassName?: string;
editor: Editor;
titleEditor?: Editor;
editorContainerClassName: string;
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
extendedEditorProps: IEditorPropsExtended;
@@ -27,12 +30,13 @@ type Props = {
id: string;
isLoading?: boolean;
isTouchDevice: boolean;
provider?: HocuspocusProvider;
state?: TCollabValue["state"];
tabIndex?: number;
};
export function PageRenderer(props: Props) {
const {
aiHandler,
bubbleMenuEnabled,
disabledExtensions,
displayConfig,
@@ -45,8 +49,10 @@ export function PageRenderer(props: Props) {
isLoading,
isTouchDevice,
tabIndex,
provider,
state,
titleEditor
} = props;
return (
<div
className={cn("frame-renderer flex-grow w-full", {
@@ -56,33 +62,47 @@ export function PageRenderer(props: Props) {
{isLoading ? (
<DocumentContentLoader className={documentLoaderClassName} />
) : (
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
isTouchDevice={isTouchDevice}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && !isTouchDevice && (
<div>
{bubbleMenuEnabled && (
<EditorBubbleMenu
disabledExtensions={disabledExtensions}
editor={editor}
extendedEditorProps={extendedEditorProps}
flaggedExtensions={flaggedExtensions}
<>
{titleEditor && (
<div className="relative w-full py-3">
<EditorContainer
editor={titleEditor}
id={id + "-title"}
isTouchDevice={isTouchDevice}
editorContainerClassName="page-title-editor bg-transparent py-3 border-none"
displayConfig={displayConfig}
>
<EditorContentWrapper
editor={titleEditor}
id={id + "-title"}
tabIndex={tabIndex}
className="no-scrollbar placeholder-custom-text-400 bg-transparent tracking-[-2%] font-bold text-[2rem] leading-[2.375rem] w-full outline-none p-0 border-none resize-none rounded-none"
/>
)}
<BlockMenu
editor={editor}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
/>
<AIFeaturesMenu menu={aiHandler?.menu} />
</EditorContainer>
</div>
)}
</EditorContainer>
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
isTouchDevice={isTouchDevice}
provider={provider}
state={state}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && !isTouchDevice && (
<div>
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} disabledExtensions={disabledExtensions} extendedEditorProps={extendedEditorProps} flaggedExtensions={flaggedExtensions}/>}
<BlockMenu
editor={editor}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
/>
</div>
)}
</EditorContainer>
</>
)}
</div>
);
@@ -1,13 +1,17 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Editor } from "@tiptap/react";
import type { FC, ReactNode } from "react";
import { useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// components
import type { TCollabValue } from "@/contexts";
import { LinkContainer } from "@/plane-editor/components/link-container";
// plugins
import { nodeHighlightPluginKey } from "@/plugins/highlight";
// types
import type { TDisplayConfig } from "@/types";
@@ -18,12 +22,85 @@ type Props = {
editorContainerClassName: string;
id: string;
isTouchDevice: boolean;
provider?: HocuspocusProvider | undefined;
state?: TCollabValue["state"];
};
export function EditorContainer(props: Props) {
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice } = props;
export const EditorContainer: FC<Props> = (props) => {
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice, provider, state } = props;
// refs
const containerRef = useRef<HTMLDivElement>(null);
const hasScrolledOnce = useRef(false);
const scrollToNode = useCallback(
(nodeId: string) => {
if (!editor) return false;
const doc = editor.state.doc;
let pos: number | null = null;
doc.descendants((node, position) => {
if (node.attrs && node.attrs.id === nodeId) {
pos = position;
return false;
}
});
if (pos === null) {
return false;
}
const nodePosition = pos;
const tr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId });
editor.view.dispatch(tr);
requestAnimationFrame(() => {
const domNode = editor.view.nodeDOM(nodePosition);
if (domNode instanceof HTMLElement) {
domNode.scrollIntoView({ behavior: "instant", block: "center" });
}
});
editor.once("focus", () => {
const clearTr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId: null });
editor.view.dispatch(clearTr);
});
hasScrolledOnce.current = true;
return true;
},
[editor]
);
useEffect(() => {
const nodeId = window.location.href.split("#")[1];
const handleSynced = () => scrollToNode(nodeId);
if (nodeId && !hasScrolledOnce.current) {
if (provider && state) {
const { hasCachedContent } = state;
// If the provider is synced or the cached content is available and the server is disconnected, scroll to the node
if (hasCachedContent) {
const hasScrolled = handleSynced();
if (!hasScrolled) {
provider.on("synced", handleSynced);
}
} else if (provider.isSynced) {
handleSynced();
} else {
provider.on("synced", handleSynced);
}
} else {
handleSynced();
}
return () => {
if (provider) {
provider.off("synced", handleSynced);
}
};
}
}, [scrollToNode, provider, state]);
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (event.target !== event.currentTarget) return;
@@ -88,7 +165,6 @@ export function EditorContainer(props: Props) {
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
{
"active-editor": editor?.isFocused && editor?.isEditable,
"wide-layout": displayConfig.wideLayout,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
@@ -3,17 +3,22 @@ import type { Editor } from "@tiptap/react";
import type { FC, ReactNode } from "react";
type Props = {
className?: string;
children?: ReactNode;
editor: Editor | null;
id: string;
tabIndex?: number;
};
export function EditorContentWrapper(props: Props) {
const { editor, children, tabIndex, id } = props;
export const EditorContentWrapper: FC<Props> = (props) => {
const { editor, className, children, tabIndex, id } = props;
return (
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
<div
tabIndex={tabIndex}
onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}
className={className}
>
<EditorContent editor={editor} id={id} />
{children}
</div>
@@ -0,0 +1,32 @@
import React, { createContext, useContext } from "react";
// hooks
import { useYjsSetup } from "@/hooks/use-yjs-setup";
export type TCollabValue = NonNullable<ReturnType<typeof useYjsSetup>>;
const CollabContext = createContext<TCollabValue | null>(null);
type CollabProviderProps = Parameters<typeof useYjsSetup>[0] & {
fallback?: React.ReactNode;
children: React.ReactNode;
};
export function CollaborationProvider({ fallback = null, children, ...args }: CollabProviderProps) {
const setup = useYjsSetup(args);
// Only wait for provider setup, not content ready
// Consumers can check state.isDocReady to gate content rendering
if (!setup) {
return <>{fallback}</>;
}
return <CollabContext.Provider value={setup}>{children}</CollabContext.Provider>;
}
export function useCollaboration(): TCollabValue {
const ctx = useContext(CollabContext);
if (!ctx) {
throw new Error("useCollaboration must be used inside <CollaborationProvider>");
}
return ctx; // guaranteed non-null
}
@@ -0,0 +1 @@
export * from "./collaboration-context";
@@ -0,0 +1,14 @@
import type { AnyExtension, Extensions } from "@tiptap/core";
import Document from "@tiptap/extension-document";
import Heading from "@tiptap/extension-heading";
import Text from "@tiptap/extension-text";
export const TitleExtensions: Extensions = [
Document.extend({
content: "heading",
}),
Heading.configure({
levels: [1],
}) as AnyExtension,
Text,
];
+14 -5
View File
@@ -2,25 +2,27 @@ import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Editor } from "@tiptap/core";
import { DOMSerializer } from "@tiptap/pm/model";
import * as Y from "yjs";
// plane imports
import { convertHTMLToMarkdown } from "@plane/utils";
// components
import { getEditorMenuItems } from "@/components/menus";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import { CORE_EDITOR_META } from "@/constants/meta";
// types
import type { EditorRefApi, TEditorCommands } from "@/types";
import type { EditorRefApi, IEditorProps, TEditorCommands } from "@/types";
// local imports
import { getParagraphCount } from "./common";
import { insertContentAtSavedSelection } from "./insert-content-at-cursor-position";
import { scrollSummary, scrollToNodeViaDOMCoordinates } from "./scroll-to-node";
type TArgs = {
type TArgs = Pick<IEditorProps, "getEditorMetaData"> & {
editor: Editor | null;
provider: HocuspocusProvider | undefined;
};
export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
const { editor, provider } = args;
const { editor, getEditorMetaData, provider } = args;
return {
blur: () => editor?.commands.blur(),
@@ -77,8 +79,15 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
}),
getHeadings: () => (editor ? editor.storage.headingsList?.headings : []),
getMarkDown: () => {
const markdownOutput = editor?.storage?.markdown?.getMarkdown?.() ?? "";
return markdownOutput;
if (!editor) return "";
const editorHTML = editor.getHTML();
const metaData = getEditorMetaData(editorHTML);
// convert to markdown
const markdown = convertHTMLToMarkdown({
description_html: editorHTML,
metaData,
});
return markdown;
},
isAnyDropbarOpen: () => {
if (!editor) return false;
+37 -10
View File
@@ -1,4 +1,5 @@
import { Buffer } from "buffer";
import type { Extensions } from "@tiptap/core";
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
@@ -9,10 +10,12 @@ import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
} from "@/extensions/core-without-props";
import { TitleExtensions } from "@/extensions/title-extension";
// editor extension configs
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
export const TITLE_EDITOR_EXTENSIONS: Extensions = TitleExtensions;
// editor schemas
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
@@ -45,9 +48,10 @@ export const convertBinaryDataToBase64String = (document: Uint8Array): string =>
/**
* @description this function decodes base64 string to binary data
* @param {string} document
* @returns {ArrayBuffer}
* @returns {Buffer<ArrayBuffer>}
*/
export const convertBase64StringToBinaryData = (document: string): ArrayBuffer => Buffer.from(document, "base64");
export const convertBase64StringToBinaryData = (document: string): Buffer<ArrayBuffer> =>
Buffer.from(document, "base64");
/**
* @description this function generates the binary equivalent of html content for the rich text editor
@@ -114,11 +118,13 @@ export const getAllDocumentFormatsFromRichTextEditorBinaryData = (
* @returns
*/
export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
description: Uint8Array
description: Uint8Array,
updateTitle: boolean
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
titleHTML?: string;
} => {
// encode binary description data
const base64Data = convertBinaryDataToBase64String(description);
@@ -130,11 +136,24 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
if (updateTitle) {
const title = yDoc.getXmlFragment("title");
const titleJSON = yXmlFragmentToProseMirrorRootNode(title, documentEditorSchema).toJSON();
const titleHTML = extractTextFromHTML(generateHTML(titleJSON, DOCUMENT_EDITOR_EXTENSIONS));
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
titleHTML,
};
} else {
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
}
};
type TConvertHTMLDocumentToAllFormatsArgs = {
@@ -170,8 +189,10 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
// Convert HTML to binary format for document editor
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
// Generate all document formats from the binary data
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
contentBinary,
false
);
allFormats = {
description: contentJSON,
description_html: contentHTML,
@@ -183,3 +204,9 @@ export const convertHTMLDocumentToAllFormats = (args: TConvertHTMLDocumentToAllF
return allFormats;
};
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
@@ -1,7 +1,9 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Extensions } from "@tiptap/core";
import Collaboration from "@tiptap/extension-collaboration";
import { useEffect, useMemo, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb";
// react
import type React from "react";
import { useEffect, useMemo } from "react";
// extensions
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
// hooks
@@ -9,10 +11,28 @@ import { useEditor } from "@/hooks/use-editor";
// plane editor extensions
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import type { TCollaborativeEditorHookProps } from "@/types";
import type {
TCollaborativeEditorHookProps,
ICollaborativeDocumentEditorProps,
IEditorPropsExtended,
TEditorHookProps,
EditorTitleRefApi,
} from "@/types";
// local imports
import { useEditorNavigation } from "./use-editor-navigation";
import { useTitleEditor } from "./use-title-editor";
export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => {
type UseCollaborativeEditorArgs = Omit<TCollaborativeEditorHookProps, "realtimeConfig" | "serverHandler" | "user"> & {
provider: HocuspocusProvider;
user: TCollaborativeEditorHookProps["user"];
actions: {
signalForcedClose: (value: boolean) => void;
};
};
export const useCollaborativeEditor = (props: UseCollaborativeEditorArgs) => {
const {
provider,
onAssetChange,
onChange,
onTransaction,
@@ -24,70 +44,27 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
extensions = [],
fileHandler,
flaggedExtensions,
getEditorMetaData,
forwardedRef,
getEditorMetaData,
handleEditorReady,
id,
mentionHandler,
dragDropEnabled = true,
isTouchDevice,
mentionHandler,
onEditorFocus,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
titleRef,
updatePageProperties,
user,
actions,
} = props;
// states
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
const [hasServerSynced, setHasServerSynced] = useState(false);
// initialize Hocuspocus provider
const provider = useMemo(
() =>
new HocuspocusProvider({
name: id,
// using user id as a token to verify the user on the server
token: JSON.stringify(user),
url: realtimeConfig.url,
onAuthenticationFailed: () => {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
},
onConnect: () => serverHandler?.onConnect?.(),
onClose: (data) => {
if (data.event.code === 1006) {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
}
},
onSynced: () => setHasServerSynced(true),
}),
[id, realtimeConfig, serverHandler, user]
);
const localProvider = useMemo(
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
[id, provider]
);
const { mainNavigationExtension, titleNavigationExtension, setMainEditor, setTitleEditor } = useEditorNavigation();
// destroy and disconnect all providers connection on unmount
useEffect(
() => () => {
provider?.destroy();
localProvider?.destroy();
},
[provider, localProvider]
);
const editor = useEditor({
disabledExtensions,
extendedEditorProps,
id,
editable,
editorProps,
editorClassName,
enableHistory: false,
extensions: [
// Memoize extensions to avoid unnecessary editor recreations
const editorExtensions = useMemo(
() => [
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
dragDropEnabled,
@@ -95,6 +72,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
HeadingListExtension,
Collaboration.configure({
document: provider.document,
field: "default",
}),
...extensions,
...DocumentEditorAdditionalExtensions({
@@ -106,26 +84,118 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
provider,
userDetails: user,
}),
mainNavigationExtension,
],
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
isTouchDevice,
mentionHandler,
onAssetChange,
onChange,
onEditorFocus,
onTransaction,
placeholder,
provider,
tabIndex,
});
[
provider,
disabledExtensions,
dragDropEnabled,
extensions,
extendedEditorProps,
fileHandler,
flaggedExtensions,
editable,
user,
mainNavigationExtension,
]
);
// Editor configuration
const editorConfig = useMemo<TEditorHookProps>(
() => ({
disabledExtensions,
extendedEditorProps,
id,
editable,
editorProps,
editorClassName,
enableHistory: false,
extensions: editorExtensions,
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
isTouchDevice,
mentionHandler,
onAssetChange,
onChange,
onEditorFocus,
onTransaction,
placeholder,
provider,
tabIndex,
}),
[
provider,
disabledExtensions,
extendedEditorProps,
id,
editable,
editorProps,
editorClassName,
editorExtensions,
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
isTouchDevice,
mentionHandler,
onAssetChange,
onChange,
onEditorFocus,
onTransaction,
placeholder,
tabIndex,
]
);
const editor = useEditor(editorConfig);
const titleExtensions = useMemo(
() => [
Collaboration.configure({
document: provider.document,
field: "title",
}),
titleNavigationExtension,
],
[provider, titleNavigationExtension]
);
const titleEditorConfig = useMemo<{
id: string;
editable: boolean;
provider: HocuspocusProvider;
titleRef?: React.MutableRefObject<EditorTitleRefApi | null>;
updatePageProperties?: ICollaborativeDocumentEditorProps["updatePageProperties"];
extensions: Extensions;
extendedEditorProps?: IEditorPropsExtended;
}>(
() => ({
id,
editable,
provider,
titleRef,
updatePageProperties,
extensions: titleExtensions,
extendedEditorProps,
}),
[provider, id, editable, titleRef, updatePageProperties, titleExtensions, extendedEditorProps]
);
const titleEditor = useTitleEditor(titleEditorConfig as Parameters<typeof useTitleEditor>[0]);
useEffect(() => {
if (editor && titleEditor) {
setMainEditor(editor);
setTitleEditor(titleEditor);
}
}, [editor, titleEditor, setMainEditor, setTitleEditor]);
return {
editor,
hasServerConnectionFailed,
hasServerSynced,
titleEditor,
};
};
@@ -0,0 +1,169 @@
import type { Editor } from "@tiptap/core";
import { Extension } from "@tiptap/core";
import { useCallback, useRef } from "react";
/**
* Creates a title editor extension that enables keyboard navigation to the main editor
*
* @param getMainEditor Function to get the main editor instance
* @returns A Tiptap extension with keyboard shortcuts
*/
export const createTitleNavigationExtension = (getMainEditor: () => Editor | null) =>
Extension.create({
name: "titleEditorNavigation",
priority: 10,
addKeyboardShortcuts() {
return {
// Arrow down at end of title - Move to main editor
ArrowDown: () => {
const mainEditor = getMainEditor();
if (!mainEditor) return false;
// If cursor is at the end of the title
mainEditor.commands.focus("start");
return true;
},
// Right arrow at end of title - Move to main editor
ArrowRight: ({ editor: titleEditor }) => {
const mainEditor = getMainEditor();
if (!mainEditor) return false;
const { from, to } = titleEditor.state.selection;
const documentLength = titleEditor.state.doc.content.size;
// If cursor is at the end of the title
if (from === to && to === documentLength - 1) {
mainEditor.commands.focus("start");
return true;
}
return false;
},
// Enter - Create new line in main editor and focus
Enter: () => {
const mainEditor = getMainEditor();
if (!mainEditor) return false;
// Focus at the start of the main editor
mainEditor.chain().focus().insertContentAt(0, { type: "paragraph" }).run();
return true;
},
};
},
});
/**
* Creates a main editor extension that enables keyboard navigation to the title editor
*
* @param getTitleEditor Function to get the title editor instance
* @returns A Tiptap extension with keyboard shortcuts
*/
export const createMainNavigationExtension = (getTitleEditor: () => Editor | null) =>
Extension.create({
name: "mainEditorNavigation",
priority: 10,
addKeyboardShortcuts() {
return {
// Arrow up at start of main editor - Move to title editor
ArrowUp: ({ editor: mainEditor }) => {
const titleEditor = getTitleEditor();
if (!titleEditor) return false;
const { from, to } = mainEditor.state.selection;
// If cursor is at the start of the main editor
if (from === 1 && to === 1) {
titleEditor.commands.focus("end");
return true;
}
return false;
},
// Left arrow at start of main editor - Move to title editor
ArrowLeft: ({ editor: mainEditor }) => {
const titleEditor = getTitleEditor();
if (!titleEditor) return false;
const { from, to } = mainEditor.state.selection;
// If cursor is at the absolute start of the main editor
if (from === 1 && to === 1) {
titleEditor.commands.focus("end");
return true;
}
return false;
},
// Backspace - Special handling for first paragraph
Backspace: ({ editor }) => {
const titleEditor = getTitleEditor();
if (!titleEditor) return false;
const { from, to, empty } = editor.state.selection;
// Only handle when cursor is at position 1 with empty selection
if (from === 1 && to === 1 && empty) {
const firstNode = editor.state.doc.firstChild;
// If first node is a paragraph
if (firstNode && firstNode.type.name === "paragraph") {
// If paragraph is already empty, delete it and focus title editor
if (firstNode.content.size === 0) {
editor.commands.deleteNode("paragraph");
// Use setTimeout to ensure the node is deleted before changing focus
setTimeout(() => titleEditor.commands.focus("end"), 0);
return true;
}
// If paragraph is not empty, just move focus to title editor
else {
titleEditor.commands.focus("end");
return true;
}
}
}
return false;
},
};
},
});
/**
* Hook to manage navigation between title and main editors
*
* Creates extension factories for keyboard navigation between editors
* and maintains references to both editors
*
* @returns Object with editor setters and extensions
*/
export const useEditorNavigation = () => {
// Create refs to store editor instances
const titleEditorRef = useRef<Editor | null>(null);
const mainEditorRef = useRef<Editor | null>(null);
// Create stable getter functions
const getTitleEditor = useCallback(() => titleEditorRef.current, []);
const getMainEditor = useCallback(() => mainEditorRef.current, []);
// Create stable setter functions
const setTitleEditor = useCallback((editor: Editor | null) => {
titleEditorRef.current = editor;
}, []);
const setMainEditor = useCallback((editor: Editor | null) => {
mainEditorRef.current = editor;
}, []);
// Create extension factories that access editor refs
const titleNavigationExtension = createTitleNavigationExtension(getMainEditor);
const mainNavigationExtension = createMainNavigationExtension(getTitleEditor);
return {
setTitleEditor,
setMainEditor,
titleNavigationExtension,
mainNavigationExtension,
};
};
+10 -1
View File
@@ -132,7 +132,16 @@ export const useEditor = (props: TEditorHookProps) => {
onAssetChange(assets);
}, [assetsList?.assets, onAssetChange]);
useImperativeHandle(forwardedRef, () => getEditorRefHelpers({ editor, provider }), [editor, provider]);
useImperativeHandle(
forwardedRef,
() =>
getEditorRefHelpers({
editor,
getEditorMetaData,
provider,
}),
[editor, getEditorMetaData, provider]
);
if (!editor) {
return null;

Some files were not shown because too many files have changed in this diff Show More