Compare commits

...

28 Commits

Author SHA1 Message Date
Palanikannan M 202336dd9c fix: rendering node views reliably 2025-12-01 16:15:33 +05:30
Vipin Chaudhary 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-saikrishnakanth 27bf2575bd [WEB-5534] fix: intake state dropdown (#8199) 2025-12-01 12:50:01 +05:30
b-saikrishnakanth 22bb3c5ecc [WEB-5490] fix: intake section filter persists incorrectly across projects (#8187) 2025-12-01 12:49:05 +05:30
Aaryan Khandelwal 6b85d67f6c [WIKI-788] regression: copy markdown option #8200 2025-12-01 12:48:28 +05:30
Prateek Shourya 123f59e74b [WEB-5525] improvement: update WorkspaceMenuRoot to use variant prop for layout adjustments (#8196) 2025-11-28 19:00:25 +05:30
sriram veeraghanta c7bf912cf2 fix: state group choices (#8198) 2025-11-28 18:06:00 +05:30
Aaryan Khandelwal 2980836015 [WEB-5527] fix: extended sidebar (#8197) 2025-11-28 16:54:53 +05:30
Bavisetti Narayan 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-saikrishnakanth dbc5a6348d fix: disable timezone selection button for non-admin users (#8195) 2025-11-28 16:14:43 +05:30
Aaryan Khandelwal c685042a47 regression: projects breadcrumb in accordion layout (#8194) 2025-11-28 14:05:38 +05:30
Vipin Chaudhary 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 Shourya 3c84e75350 [WEB-5510] fix: handle null values in context indicator for improved stability (#8190) 2025-11-27 20:53:42 +05:30
Aaryan Khandelwal 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 Khandelwal 9bcb1fa469 [WEB-5515]: comments ordering (#8193)
* fix: comments ordering

* fix: comment timestamp:
2025-11-27 20:51:06 +05:30
Sangeetha 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
sriramveeraghanta 73c317f283 chore(deps): adding pacakges to resolutions to resolve vulnerbailities 2025-11-27 16:37:36 +05:30
Aaryan Khandelwal a0da806a79 [WEB-5520] fix: comments UI in space app #8189 2025-11-27 15:58:10 +05:30
Aaryan Khandelwal eddf80aaed [WEB-5511] regression: revamped navigation UI bugs (#8183) 2025-11-26 18:51:03 +05:30
Aaryan Khandelwal 05b1c147a9 [WEB-5506] regression: navigation revamp bugs (#8180) 2025-11-26 16:25:29 +05:30
Prateek Shourya ae7898aaee fix: move X-Frame-Options header to auth page for enhanced security (#8179) 2025-11-26 13:57:46 +05:30
Anmol Singh Bhatia 4806bdf99c [WEB-5170] feat: navigation revamp (#8162) 2025-11-26 12:56:11 +05:30
Prateek Shourya 37c59ef0d1 [WEB-5507] fix: remove module checks in analytics sidebar link handlers (#8177) 2025-11-26 00:02:14 +05:30
Prateek Shourya 3f11183768 [WEB-5508] fix: improve error handling in AuthFormRoot by ensuring safe access to error_code (#8178) 2025-11-25 23:59:21 +05:30
Aaryan Khandelwal d38147b875 fix: build error (#8171) 2025-11-25 19:15:35 +05:30
Prateek Shourya 3436c4f1f5 [WEB-5501] refactor: optimize component structures and improve hooks (#8174)
* [WEB-5501] refactor: optimize component structures and improve hooks

- Updated type definitions in AppProvider to use React.ReactNode for children.
- Enhanced HomePeekOverviewsRoot by using MobX observer and integrating issue detail hook.
- Optimized ContentOverflowWrapper to prevent unnecessary re-renders by adjusting useEffect dependencies.
- Updated DashboardQuickLinks to include necessary dependencies in useCallback.
- Refactored GlobalShortcutsProvider to utilize refs for context and handler management, improving performance.
- Changed useCurrentTime to update every minute instead of every second.
- Refactored outside click hooks to use useCallback for better performance.
- Improved IntercomProvider and PostHogProvider to prevent multiple initializations using refs.

* refactor: simplify conditional rendering in HomePeekOverviewsRoot component

* refactor: improve outside click detection in sidebar and peek overview hooks

* refactor: enhance IntercomProvider and PostHogProvider with hydration state management
2025-11-25 18:52:20 +05:30
sriramveeraghanta 31e8563725 chore(deps): upgrade sentry pacakges 2025-11-25 16:25:07 +05:30
b-saikrishnakanth 5ddfd0e1a9 [WEB-5497] fix: update change email button label for localization consistency #8173 2025-11-25 15:47:14 +05:30
210 changed files with 5469 additions and 1990 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
View File
@@ -14,6 +14,7 @@ from plane.app.views import (
ProjectPublicCoverImagesEndpoint,
UserProjectRolesEndpoint,
ProjectArchiveUnarchiveEndpoint,
ProjectMemberPreferenceEndpoint,
)
@@ -125,4 +126,9 @@ urlpatterns = [
ProjectArchiveUnarchiveEndpoint.as_view(),
name="project-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/preferences/member/<uuid:member_id>/",
ProjectMemberPreferenceEndpoint.as_view(),
name="project-member-preference",
),
]
+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"}),
-5
View File
@@ -253,9 +253,4 @@ urlpatterns = [
WorkspaceUserPreferenceViewSet.as_view(),
name="workspace-user-preference",
),
path(
"workspaces/<str:slug>/sidebar-preferences/<str:key>/",
WorkspaceUserPreferenceViewSet.as_view(),
name="workspace-user-preference",
),
]
+2 -1
View File
@@ -18,6 +18,7 @@ from .project.member import (
ProjectMemberViewSet,
ProjectMemberUserEndpoint,
UserProjectRolesEndpoint,
ProjectMemberPreferenceEndpoint,
)
from .user.base import (
@@ -79,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
]
)
@@ -8,6 +8,7 @@ from plane.app.serializers import (
ProjectMemberSerializer,
ProjectMemberAdminSerializer,
ProjectMemberRoleSerializer,
ProjectMemberPreferenceSerializer,
)
from plane.app.permissions import WorkspaceUserPermission
@@ -300,3 +301,32 @@ class UserProjectRolesEndpoint(BaseAPIView):
project_members = {str(member["project_id"]): member["role"] for member in project_members}
return Response(project_members, status=status.HTTP_200_OK)
class ProjectMemberPreferenceEndpoint(BaseAPIView):
def get_queryset(self, slug, project_id, member_id):
return ProjectMember.objects.get(
project_id=project_id,
member_id=member_id,
workspace__slug=slug,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def patch(self, request, slug, project_id, member_id):
project_member = self.get_queryset(slug, project_id, member_id)
serializer = ProjectMemberPreferenceSerializer(project_member, {"preferences": request.data}, partial=True)
if serializer.is_valid():
serializer.save()
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_queryset(slug, project_id, member_id)
serializer = ProjectMemberPreferenceSerializer(project_member)
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)
],
@@ -65,15 +75,23 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def patch(self, request, slug, key):
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug, user=request.user).first()
def patch(self, request, slug):
for data in request.data:
key = data.pop("key", None)
if not key:
continue
if preference:
serializer = WorkspaceUserPreferenceSerializer(preference, data=request.data, partial=True)
preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug).first()
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)
if not preference:
continue
return Response({"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND)
if "is_pinned" in data:
preference.is_pinned = data["is_pinned"]
if "sort_order" in data:
preference.sort_order = data["sort_order"]
preference.save(update_fields=["is_pinned", "sort_order"])
return Response({"message": "Successfully updated"}, status=status.HTTP_200_OK)
+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)
+1 -1
View File
@@ -59,7 +59,7 @@ def get_default_props():
def get_default_preferences():
return {"pages": {"block_display": True}}
return {"pages": {"block_display": True}, "navigation": {"default_tab": "work_items", "hide_in_more_menu": []}}
class Project(BaseModel):
+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}>"
+1
View File
@@ -417,6 +417,7 @@ class WorkspaceUserPreference(BaseModel):
DRAFTS = "drafts", "Drafts"
YOUR_WORK = "your_work", "Your Work"
ARCHIVES = "archives", "Archives"
STICKIES = "stickies", "Stickies"
workspace = models.ForeignKey(
"db.Workspace",
+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():
+5
View File
@@ -9,6 +9,11 @@ import { LogoSpinner } from "@/components/common/logo-spinner";
import { AuthView } from "@/components/views";
// hooks
import { useUser } from "@/hooks/store/use-user";
import type { Route } from "./+types/page";
export const headers: Route.HeadersFunction = () => ({
"X-Frame-Options": "SAMEORIGIN",
});
const HomePage = observer(function HomePage() {
const { data: currentUser, isAuthenticated, isInitializing } = useUser();
+2 -4
View File
@@ -1,6 +1,5 @@
import * as Sentry from "@sentry/react-router";
import { Links, Meta, Outlet, Scripts } from "react-router";
import type { HeadersFunction, LinksFunction } from "react-router";
// assets
import appleTouchIcon from "@/app/assets/favicon/apple-touch-icon.png?url";
import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url";
@@ -18,7 +17,7 @@ import { AppProviders } from "./providers";
const APP_TITLE = "Plane Publish | Make your Plane boards public with one-click";
const APP_DESCRIPTION = "Plane Publish is a customer feedback management tool built on top of plane.so";
export const links: LinksFunction = () => [
export const links: Route.LinksFunction = () => [
{ rel: "apple-touch-icon", sizes: "180x180", href: appleTouchIcon },
{ rel: "icon", type: "image/png", sizes: "32x32", href: favicon32 },
{ rel: "icon", type: "image/png", sizes: "16x16", href: favicon16 },
@@ -27,9 +26,8 @@ export const links: LinksFunction = () => [
{ rel: "stylesheet", href: globalStyles },
];
export const headers: HeadersFunction = () => ({
export const headers: Route.HeadersFunction = () => ({
"Referrer-Policy": "origin-when-cross-origin",
"X-Frame-Options": "SAMEORIGIN",
"X-Content-Type-Options": "nosniff",
"X-DNS-Prefetch-Control": "on",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
@@ -79,21 +79,23 @@ export const LiteTextEditor = React.forwardRef(function LiteTextEditor(
// overriding the containerClassName to add relative class passed
containerClassName={cn(containerClassName, "relative")}
/>
<IssueCommentToolbar
executeCommand={(item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
editorRef?.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}}
isSubmitting={isSubmitting}
showSubmitButton={showSubmitButton}
handleSubmit={(e) => rest.onEnterKeyPress?.(e)}
isCommentEmpty={isEmpty}
editorRef={editorRef}
/>
{editable && (
<IssueCommentToolbar
executeCommand={(item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
editorRef?.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}}
isSubmitting={isSubmitting}
showSubmitButton={showSubmitButton}
handleSubmit={(e) => rest.onEnterKeyPress?.(e)}
isCommentEmpty={isEmpty}
editorRef={editorRef}
/>
)}
</div>
);
});
@@ -96,6 +96,9 @@ export const AddComment = observer(function AddComment(props: Props) {
setUploadAssetIds((prev) => [...prev, asset_id]);
return asset_id;
}}
displayConfig={{
fontSize: "small-font",
}}
/>
)}
/>
@@ -42,7 +42,7 @@ export const CommentCard = observer(function CommentCard(props: Props) {
control,
formState: { isSubmitting },
handleSubmit,
} = useForm<any>({
} = useForm<TIssuePublicComment>({
defaultValues: { comment_html: comment.comment_html },
});
@@ -120,6 +120,9 @@ export const CommentCard = observer(function CommentCard(props: Props) {
const { asset_id } = await uploadCommentAsset(file, anchor, comment.id);
return asset_id;
}}
displayConfig={{
fontSize: "small-font",
}}
/>
)}
/>
-1
View File
@@ -1 +0,0 @@
export * from "ce/components/editor";
@@ -2,13 +2,13 @@ import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { useParams, usePathname } from "next/navigation";
import { SIDEBAR_WIDTH } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
// components
import { ResizableSidebar } from "@/components/sidebar/resizable-sidebar";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useAppRail } from "@/hooks/use-app-rail";
// local imports
import { ExtendedAppSidebar } from "./extended-sidebar";
import { AppSidebar } from "./sidebar";
@@ -26,14 +26,19 @@ export const ProjectAppSidebar = observer(function ProjectAppSidebar() {
const { storedValue, setValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH);
// states
const [sidebarWidth, setSidebarWidth] = useState<number>(storedValue ?? SIDEBAR_WIDTH);
// hooks
const { shouldRenderAppRail } = useAppRail();
// routes
const { workspaceSlug } = useParams();
const pathname = usePathname();
// derived values
const isAnyExtendedSidebarOpen = isExtendedSidebarOpened;
const isNotificationsPath = pathname.includes(`/${workspaceSlug}/notifications`);
// handlers
const handleWidthChange = (width: number) => setValue(width);
if (isNotificationsPath) return null;
return (
<>
<ResizableSidebar
@@ -55,7 +60,6 @@ export const ProjectAppSidebar = observer(function ProjectAppSidebar() {
}
isAnyExtendedSidebarExpanded={isAnyExtendedSidebarOpen}
isAnySidebarDropdownOpen={isAnySidebarDropdownOpen}
disablePeekTrigger={shouldRenderAppRail}
>
<AppSidebar />
</ResizableSidebar>
@@ -1,61 +1,59 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EProjectFeatureKey } from "@plane/constants";
import { Breadcrumbs, Header } from "@plane/ui";
import { Header, Row } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
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 { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
// local components
import { WorkItemDetailsHeader } from "./work-item-header";
export const ProjectIssueDetailsHeader = observer(function ProjectIssueDetailsHeader() {
export const ProjectWorkItemDetailsHeader = observer(function ProjectWorkItemDetailsHeader() {
// router
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
// store hooks
const { getProjectById, loader } = useProject();
const { sidebarCollapsed } = useAppTheme();
const {
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail();
// derived values
const issueId = getIssueIdByIdentifier(workItem?.toString());
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
const projectId = issueDetails ? issueDetails?.project_id : undefined;
const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined;
if (!workspaceSlug || !projectId || !issueId) return null;
const issueDetails = issueId ? getIssueById(issueId?.toString()) : undefined;
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
return (
<Header>
<Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.WORK_ITEMS}
/>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""}
/>
}
/>
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
{projectId && issueId && (
<IssueDetailQuickActions
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
issueId={issueId?.toString()}
/>
)}
</Header.RightItem>
</Header>
<>
{projectPreferences.navigationMode === "horizontal" && (
<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 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}
projectId={issueDetails?.project_id?.toString() ?? ""}
/>
</Header.LeftItem>
</Header>
</div>
</div>
</Row>
</div>
)}
<AppHeader header={<WorkItemDetailsHeader />} />
</>
);
});
@@ -1,13 +1,12 @@
// components
import { Outlet } from "react-router";
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectIssueDetailsHeader } from "./header";
import { ProjectWorkItemDetailsHeader } from "./header";
export default function ProjectIssueDetailsLayout() {
return (
<>
<AppHeader header={<ProjectIssueDetailsHeader />} />
<ProjectWorkItemDetailsHeader />
<ContentWrapper className="overflow-hidden">
<Outlet />
</ContentWrapper>
@@ -0,0 +1,69 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane ui
import { WorkItemsIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
// hooks
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
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
// store hooks
const { getProjectById, loader } = useProject();
const {
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail();
// derived values
const issueId = getIssueIdByIdentifier(workItem?.toString());
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
const projectId = issueDetails ? issueDetails?.project_id : undefined;
const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined;
if (!workspaceSlug || !projectId || !issueId) return null;
return (
<Header>
<Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Work Items"
href={`/${workspaceSlug}/projects/${projectId}/issues/`}
icon={<WorkItemsIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""}
/>
}
/>
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
{projectId && issueId && (
<IssueDetailQuickActions
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
issueId={issueId?.toString()}
/>
)}
</Header.RightItem>
</Header>
);
});
@@ -1,10 +1,11 @@
import React, { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { Plus, Search } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { copyUrlToClipboard, orderJoinedProjects } from "@plane/utils";
@@ -75,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(() => {
@@ -101,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 px-4">
<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 && (
@@ -110,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);
}}
@@ -131,21 +133,33 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
/>
</div>
</div>
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 px-4">
{filteredProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => handleCopyText(projectId)}
projectListType={"JOINED"}
disableDrag={false}
disableDrop={false}
isLastChild={index === joinedProjects.length - 1}
handleOnProjectDrop={handleOnProjectDrop}
renderInExtendedSidebar
{filteredProjects.length === 0 ? (
<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")}
assetKey="search"
assetClassName="size-20"
align="center"
/>
))}
</div>
</div>
) : (
<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}
projectId={projectId}
handleCopyText={() => handleCopyText(projectId)}
projectListType={"JOINED"}
disableDrag={false}
disableDrop={false}
isLastChild={index === filteredProjects.length - 1}
handleOnProjectDrop={handleOnProjectDrop}
renderInExtendedSidebar
/>
))}
</div>
)}
</ExtendedSidebarWrapper>
</>
);
@@ -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-[19] 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,12 +1,13 @@
import React, { useMemo, useRef } from "react";
import { useCallback, useMemo, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants";
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, EUserPermissionsLevel } from "@plane/constants";
import type { EUserWorkspaceRoles } from "@plane/types";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkspaceNavigationPreferences } from "@/hooks/use-navigation-preferences";
// plane-web imports
import { ExtendedSidebarItem } from "@/plane-web/components/workspace/sidebar/extended-sidebar-item";
import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper";
@@ -18,22 +19,38 @@ export const ExtendedAppSidebar = observer(function ExtendedAppSidebar() {
const { workspaceSlug } = useParams();
// store hooks
const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
const { updateSidebarPreference, getNavigationPreferences } = useWorkspace();
const { allowPermissions } = useUserPermissions();
const { preferences: workspacePreferences, updateWorkspaceItemSortOrder } = useWorkspaceNavigationPreferences();
// derived values
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
const currentWorkspaceNavigationPreferences = workspacePreferences.items;
const sortedNavigationItems = useMemo(
() =>
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
const sortedNavigationItems = useMemo(() => {
const slug = workspaceSlug.toString();
return WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.filter((item) => {
// Permission check
const hasPermission = allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, slug);
return hasPermission;
})
.map((item) => {
const preference = currentWorkspaceNavigationPreferences?.[item.key];
return {
...item,
sort_order: preference ? preference.sort_order : 0,
sort_order: preference?.sort_order ?? 0,
is_pinned: preference?.is_pinned ?? false,
};
}).sort((a, b) => a.sort_order - b.sort_order),
[currentWorkspaceNavigationPreferences]
);
})
.sort((a, b) => {
// First sort by pinned status (pinned items first)
if (a.is_pinned !== b.is_pinned) {
return b.is_pinned ? 1 : -1;
}
// Then sort by sort_order within each group
return a.sort_order - b.sort_order;
});
}, [workspaceSlug, currentWorkspaceNavigationPreferences, allowPermissions]);
const sortedNavigationItemsKeys = sortedNavigationItems.map((item) => item.key);
@@ -87,13 +104,10 @@ export const ExtendedAppSidebar = observer(function ExtendedAppSidebar() {
const updatedSortOrder = orderNavigationItem(sourceIndex, destinationIndex, sortedNavigationItems);
if (updatedSortOrder != undefined)
updateSidebarPreference(workspaceSlug.toString(), sourceId, {
sort_order: updatedSortOrder,
});
if (updatedSortOrder != undefined) updateWorkspaceItemSortOrder(sourceId, updatedSortOrder);
};
const handleClose = () => toggleExtendedSidebar(false);
const handleClose = useCallback(() => toggleExtendedSidebar(false), [toggleExtendedSidebar]);
return (
<ExtendedSidebarWrapper
@@ -3,6 +3,7 @@ import { Outlet } from "react-router";
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
// plane web components
import { ProjectAppSidebar } from "./_sidebar";
import { ExtendedProjectSidebar } from "./extended-project-sidebar";
function WorkspaceLayout() {
return (
@@ -12,6 +13,7 @@ function WorkspaceLayout() {
<div id="full-screen-portal" className="inset-0 absolute w-full" />
<div className="relative flex size-full overflow-hidden">
<ProjectAppSidebar />
<ExtendedProjectSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
<Outlet />
</main>
@@ -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
@@ -8,7 +8,6 @@ import {
EIssueFilterType,
EUserPermissions,
EUserPermissionsLevel,
EProjectFeatureKey,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants";
@@ -23,6 +22,7 @@ import { Breadcrumbs, BreadcrumbNavigationSearchDropdown, Header } from "@plane/
import { cn } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SwitcherLabel } from "@/components/common/switcher-label";
import { CycleQuickActions } from "@/components/cycles/quick-actions";
import {
@@ -135,10 +135,15 @@ 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()}
featureKey={EProjectFeatureKey.CYCLES}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Cycles"
href={`/${workspaceSlug}/projects/${projectId}/cycles/`}
icon={<CycleIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.Item
component={
@@ -1,26 +1,26 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { CycleIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { CyclesViewHeader } from "@/components/cycles/cycles-view-header";
// hooks
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
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
// constants
export const CyclesListHeader = observer(function CyclesListHeader() {
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
const { workspaceSlug, projectId } = useParams();
// store hooks
const { toggleCreateCycleModal } = useCommandPalette();
@@ -37,10 +37,16 @@ export const CyclesListHeader = observer(function CyclesListHeader() {
<Header>
<Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString()}
projectId={currentProjectDetails?.id ?? ""}
featureKey={EProjectFeatureKey.CYCLES}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Cycles"
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/cycles/`}
icon={<CycleIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
@@ -0,0 +1,52 @@
"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";
function ProjectLayout({ params }: Route.ComponentProps) {
// router
const { workspaceSlug, projectId } = params;
// store hooks
const { sidebarCollapsed } = useAppTheme();
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
return (
<>
{projectPreferences.navigationMode === "horizontal" && (
<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 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>
</div>
</div>
</Row>
</div>
)}
<Outlet />
</>
);
}
export default observer(ProjectLayout);
@@ -9,7 +9,6 @@ import {
ISSUE_DISPLAY_FILTERS_BY_PAGE,
EUserPermissions,
EUserPermissionsLevel,
EProjectFeatureKey,
WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants";
import { Button } from "@plane/propel/button";
@@ -21,6 +20,7 @@ import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/
import { cn } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SwitcherLabel } from "@/components/common/switcher-label";
import {
DisplayFiltersSelection,
@@ -40,7 +40,7 @@ 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
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() {
@@ -128,10 +128,17 @@ 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() ?? ""}
featureKey={EProjectFeatureKey.MODULES}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Modules"
href={`/${workspaceSlug}/projects/${projectId}/modules/`}
icon={<ModuleIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
<Breadcrumbs.Item
component={
@@ -1,21 +1,22 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel, MODULE_TRACKER_ELEMENTS } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel, MODULE_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { Button } from "@plane/propel/button";
import { ModuleIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { ModuleViewHeader } from "@/components/modules";
// hooks
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
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
// constants
export const ModulesListHeader = observer(function ModulesListHeader() {
// router
@@ -40,10 +41,16 @@ export const ModulesListHeader = observer(function ModulesListHeader() {
<Header.LeftItem>
<div>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={projectId?.toString() ?? ""}
featureKey={EProjectFeatureKey.MODULES}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Modules"
href={`/${workspaceSlug}/projects/${projectId}/modules/`}
icon={<ModuleIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
@@ -1,24 +1,21 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EProjectFeatureKey } from "@plane/constants";
// 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
// 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 {
@@ -65,10 +62,15 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
<Header.LeftItem>
<div>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.PAGES}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Pages"
href={`/${workspaceSlug}/projects/${projectId}/pages/`}
icon={<PageIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.Item
@@ -2,25 +2,21 @@ import { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
// constants
import {
EPageAccess,
EProjectFeatureKey,
PROJECT_PAGE_TRACKER_EVENTS,
PROJECT_TRACKER_ELEMENTS,
} from "@plane/constants";
import { EPageAccess, PROJECT_PAGE_TRACKER_EVENTS, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
// plane types
import { Button } from "@plane/propel/button";
import { PageIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TPage } from "@plane/types";
// plane ui
import { Breadcrumbs, Header } from "@plane/ui";
// helpers
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
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
// plane web hooks
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
export const PagesListHeader = observer(function PagesListHeader() {
@@ -28,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
@@ -74,10 +70,16 @@ export const PagesListHeader = observer(function PagesListHeader() {
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={currentProjectDetails?.id?.toString() ?? ""}
featureKey={EProjectFeatureKey.PAGES}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Pages"
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/`}
icon={<PageIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
@@ -2,27 +2,24 @@ 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,
EUserPermissions,
EUserPermissionsLevel,
EProjectFeatureKey,
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,8 @@ 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() {
@@ -121,12 +118,16 @@ export const ProjectViewIssuesHeader = observer(function ProjectViewIssuesHeader
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={projectId?.toString() ?? ""}
featureKey={EProjectFeatureKey.VIEWS}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Views"
href={`/${workspaceSlug}/projects/${projectId}/views/`}
icon={<ViewsIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.Item
component={
<BreadcrumbNavigationSearchDropdown
@@ -1,15 +1,17 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
import { EProjectFeatureKey, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { ViewsIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
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
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ProjectViewsHeader = observer(function ProjectViewsHeader() {
@@ -23,10 +25,16 @@ export const ProjectViewsHeader = observer(function ProjectViewsHeader() {
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={projectId?.toString() ?? ""}
featureKey={EProjectFeatureKey.VIEWS}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Views"
href={`/${workspaceSlug}/projects/${projectId}/views/`}
icon={<ViewsIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
@@ -1,5 +1,4 @@
import { Outlet } from "react-router";
import { AppRailProvider } from "@/hooks/context/app-rail-context";
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/content-wrapper";
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
@@ -7,13 +6,11 @@ import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
export default function WorkspaceLayout() {
return (
<AuthenticationWrapper>
<AppRailProvider>
<WorkspaceAuthWrapper>
<WorkspaceContentWrapper>
<Outlet />
</WorkspaceContentWrapper>
</WorkspaceAuthWrapper>
</AppRailProvider>
<WorkspaceAuthWrapper>
<WorkspaceContentWrapper>
<Outlet />
</WorkspaceContentWrapper>
</WorkspaceAuthWrapper>
</AuthenticationWrapper>
);
}
+1 -2
View File
@@ -1,4 +1,3 @@
import type { FC, ReactNode } from "react";
import { lazy, Suspense } from "react";
import { useTheme, ThemeProvider } from "next-themes";
import { SWRConfig } from "swr";
@@ -31,7 +30,7 @@ const IntercomProvider = lazy(function IntercomProvider() {
});
export interface IAppProvider {
children: ReactNode;
children: React.ReactNode;
}
function ToastWithTheme() {
+86 -91
View File
@@ -123,6 +123,92 @@ export const coreRoutes: RouteConfigEntry[] = [
// Project Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx", [
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx", [
// Project Issues List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/issues",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
),
]),
// Issue Detail
route(
":workspaceSlug/projects/:projectId/issues/:issueId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
),
// Cycle Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles/:cycleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
),
]),
// Cycles List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx"
),
]),
// Module Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules/:moduleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx"
),
]),
// Modules List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx"
),
]),
// View Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views/:viewId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx"
),
]),
// Views List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx"
),
]),
// Page Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages/:pageId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx"
),
]),
// Pages List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx"
),
]),
// Intake list
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/intake",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx"
),
]),
]),
// Archived Projects
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [
route(
@@ -131,97 +217,6 @@ export const coreRoutes: RouteConfigEntry[] = [
),
]),
// Project Issues
// Issues List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/issues",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
),
]),
// Issue Detail
route(
":workspaceSlug/projects/:projectId/issues/:issueId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
),
// Project Cycles
// Cycles List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx"
),
]),
// Cycle Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/cycles/:cycleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
),
]),
// Project Modules
// Modules List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx"
),
]),
// Module Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/modules/:moduleId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx"
),
]),
// Project Views
// Views List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx"
),
]),
// View Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/views/:viewId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx"
),
]),
// Project Pages
// Pages List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx"
),
]),
// Page Detail
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/pages/:pageId",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx"
),
]),
// Project Intake
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [
route(
":workspaceSlug/projects/:projectId/intake",
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx"
),
]),
// Project Archives - Issues, Cycles, Modules
// Project Archives - Issues - List
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [
@@ -0,0 +1,34 @@
// hoc/withDockItems.tsx
"use client";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { PlaneNewIcon } from "@plane/propel/icons";
import type { AppSidebarItemData } from "@/components/sidebar/sidebar-item";
import { useWorkspacePaths } from "@/hooks/use-workspace-paths";
type WithDockItemsProps = {
dockItems: (AppSidebarItemData & { shouldRender: boolean })[];
};
export function withDockItems<P extends WithDockItemsProps>(WrappedComponent: React.ComponentType<P>) {
const ComponentWithDockItems = observer((props: Omit<P, keyof WithDockItemsProps>) => {
const { workspaceSlug } = useParams();
const { isProjectsPath, isNotificationsPath } = useWorkspacePaths();
const dockItems: (AppSidebarItemData & { shouldRender: boolean })[] = [
{
label: "Projects",
icon: <PlaneNewIcon className="size-4" />,
href: `/${workspaceSlug}/`,
isActive: isProjectsPath && !isNotificationsPath,
shouldRender: true,
},
];
return <WrappedComponent {...(props as P)} dockItems={dockItems} />;
});
return ComponentWithDockItems;
}
+1 -1
View File
@@ -1 +1 @@
export * from "./root";
export * from "./app-rail-hoc";
-5
View File
@@ -1,5 +0,0 @@
import React from "react";
export function AppRailRoot() {
return <></>;
}
+7 -20
View File
@@ -1,30 +1,17 @@
import type { FC } from "react";
// plane imports
import type { EProjectFeatureKey } from "@plane/constants";
// local components
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
import { ProjectBreadcrumb } from "./project";
import { ProjectFeatureBreadcrumb } from "./project-feature";
type TCommonProjectBreadcrumbProps = {
workspaceSlug: string;
projectId: string;
featureKey?: EProjectFeatureKey;
isLast?: boolean;
};
export function CommonProjectBreadcrumbs(props: TCommonProjectBreadcrumbProps) {
const { workspaceSlug, projectId, featureKey, isLast = false } = props;
return (
<>
<ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />
{featureKey && (
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={featureKey}
isLast={isLast}
/>
)}
</>
);
const { workspaceSlug, projectId } = props;
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
if (projectPreferences.navigationMode === "horizontal") return null;
return <ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />;
}
@@ -1,15 +1,13 @@
import type { FC } from "react";
import type { ReactNode } from "react";
import { observer } from "mobx-react";
// plane imports
import { EProjectFeatureKey } from "@plane/constants";
import type { ISvgIcons } from "@plane/propel/icons";
import { BreadcrumbNavigationDropdown, Breadcrumbs } from "@plane/ui";
import type { EProjectFeatureKey } from "@plane/constants";
import { Breadcrumbs } from "@plane/ui";
// components
import { SwitcherLabel } from "@/components/common/switcher-label";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
// local imports
import { getProjectFeatureNavigation } from "../projects/navigation/helper";
@@ -25,8 +23,6 @@ export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcru
props: TProjectFeatureBreadcrumbProps
) {
const { workspaceSlug, projectId, featureKey, isLast = false, additionalNavigationItems } = props;
// router
const router = useAppRouter();
// store hooks
const { getPartialProjectById } = useProject();
// derived values
@@ -39,27 +35,21 @@ export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcru
// if additional navigation items are provided, add them to the navigation items
const allNavigationItems = [...(additionalNavigationItems || []), ...navigationItems];
const currentNavigationItem = allNavigationItems.find((item) => item.key === featureKey);
const icon = currentNavigationItem?.icon as ReactNode;
const name = currentNavigationItem?.name;
const href = currentNavigationItem?.href;
return (
<>
<Breadcrumbs.Item
component={
<BreadcrumbNavigationDropdown
selectedItemKey={featureKey}
navigationItems={allNavigationItems
.filter((item) => item.shouldRender)
.map((item) => ({
key: item.key,
title: item.name,
customContent: <SwitcherLabel name={item.name} LabelIcon={item.icon as FC<ISvgIcons>} />,
action: () => router.push(item.href),
icon: item.icon as FC<ISvgIcons>,
}))}
handleOnClick={() => {
router.push(
`/${workspaceSlug}/projects/${projectId}/${featureKey === EProjectFeatureKey.WORK_ITEMS ? "issues" : featureKey}/`
);
}}
<BreadcrumbLink
key={featureKey}
label={name}
isLast={isLast}
href={href}
icon={<Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
/>
}
showSeparator={false}
@@ -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;
@@ -1,10 +1,9 @@
import type { FC, ReactNode } from "react";
import type { ReactNode } from "react";
import { useRef } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TIssueComment } from "@plane/types";
import { EIssueCommentAccessSpecifier } from "@plane/types";
import { Avatar, Tooltip } from "@plane/ui";
import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils";
// hooks
@@ -56,9 +55,7 @@ export const CommentBlock = observer(function CommentBlock(props: TCommentBlock)
<div className="flex w-full gap-2">
<div className="flex-1 flex flex-wrap items-center gap-1">
<div className="flex items-center gap-1">
<span className="text-xs font-medium">
{`${displayName}${comment.access === EIssueCommentAccessSpecifier.EXTERNAL ? " (External User)" : ""}`}
</span>
<span className="text-xs font-medium">{displayName}</span>
</div>
<div className="text-xs text-custom-text-300">
commented{" "}
@@ -67,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;
}
@@ -1,9 +1,9 @@
import { observer } from "mobx-react";
import { IssuePeekOverview } from "@/components/issues/peek-overview";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
export function HomePeekOverviewsRoot() {
return (
<>
<IssuePeekOverview />
</>
);
}
export const HomePeekOverviewsRoot = observer(function HomePeekOverviewsRoot() {
const { peekIssue } = useIssueDetail();
return peekIssue ? <IssuePeekOverview /> : null;
});
+14 -7
View File
@@ -9,14 +9,15 @@ import {
SPACE_BASE_PATH,
SPACE_BASE_URL,
WORK_ITEM_TRACKER_ELEMENTS,
EProjectFeatureKey,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { WorkItemsIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { EIssuesStoreType } from "@plane/types";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { CountChip } from "@/components/common/count-chip";
// constants
import { HeaderFilters } from "@/components/issues/filters";
@@ -28,8 +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
import { CommonProjectBreadcrumbs } from "../breadcrumbs/common";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const IssuesHeader = observer(function IssuesHeader() {
// router
@@ -62,10 +63,16 @@ 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()}
featureKey={EProjectFeatureKey.WORK_ITEMS}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Work Items"
href={`/${workspaceSlug}/projects/${projectId}/issues/`}
icon={<WorkItemsIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
@@ -0,0 +1,2 @@
export * from "./use-navigation-items";
export * from "./top-navigation-root";
@@ -0,0 +1,38 @@
// components
import { observer } from "mobx-react";
import { cn } from "@plane/utils";
import { TopNavPowerK } from "@/components/navigation";
import { HelpMenuRoot } from "@/components/workspace/sidebar/help-section/root";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
export const TopNavigationRoot = observer(() => {
const { preferences } = useAppRailPreferences();
const showLabel = preferences.displayMode === "icon_with_label";
return (
<div
className={cn("flex items-center min-h-11 w-full px-3.5 z-[27] transition-all duration-300", {
"px-2": !showLabel,
})}
>
{/* Workspace Menu */}
<div className="shrink-0 flex-1">
<WorkspaceMenuRoot variant="top-navigation" />
</div>
{/* Power K Search */}
<div className="shrink-0">
<TopNavPowerK />
</div>
{/* Additional Actions */}
<div className="shrink-0 flex-1 flex gap-1 items-center justify-end">
<HelpMenuRoot />
<div className="flex items-center justify-center size-8 hover:bg-custom-background-80 rounded-md">
<UserMenuRoot size="xs" />
</div>
</div>
</div>
);
});
@@ -0,0 +1,109 @@
import { useMemo, useCallback } from "react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { CycleIcon, IntakeIcon, ModuleIcon, PageIcon, ViewsIcon, WorkItemsIcon } from "@plane/propel/icons";
import type { EUserProjectRoles, IPartialProject } from "@plane/types";
import type { TNavigationItem } from "@/components/navigation/tab-navigation-root";
type UseNavigationItemsProps = {
workspaceSlug: string;
projectId: string;
project?: IPartialProject;
allowPermissions: (
access: EUserPermissions[] | EUserProjectRoles[],
level: EUserPermissionsLevel,
workspaceSlug: string,
projectId: string
) => boolean;
};
export const useNavigationItems = ({
workspaceSlug,
projectId,
project,
allowPermissions,
}: UseNavigationItemsProps): TNavigationItem[] => {
// Base navigation items
const baseNavigation = useCallback(
(workspaceSlug: string, projectId: string): TNavigationItem[] => [
{
i18n_key: "sidebar.work_items",
key: "work_items",
name: "Work items",
href: `/${workspaceSlug}/projects/${projectId}/issues`,
icon: WorkItemsIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: true,
sortOrder: 1,
},
{
i18n_key: "sidebar.cycles",
key: "cycles",
name: "Cycles",
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
icon: CycleIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
shouldRender: !!project?.cycle_view,
sortOrder: 2,
},
{
i18n_key: "sidebar.modules",
key: "modules",
name: "Modules",
href: `/${workspaceSlug}/projects/${projectId}/modules`,
icon: ModuleIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
shouldRender: !!project?.module_view,
sortOrder: 3,
},
{
i18n_key: "sidebar.views",
key: "views",
name: "Views",
href: `/${workspaceSlug}/projects/${projectId}/views`,
icon: ViewsIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: !!project?.issue_views_view,
sortOrder: 4,
},
{
i18n_key: "sidebar.pages",
key: "pages",
name: "Pages",
href: `/${workspaceSlug}/projects/${projectId}/pages`,
icon: PageIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: !!project?.page_view,
sortOrder: 5,
},
{
i18n_key: "sidebar.intake",
key: "intake",
name: "Intake",
href: `/${workspaceSlug}/projects/${projectId}/intake`,
icon: IntakeIcon,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
shouldRender: !!project?.inbox_view,
sortOrder: 6,
},
],
[project]
);
// Combine, filter, and sort navigation items
const navigationItems = useMemo(() => {
const navItems = baseNavigation(workspaceSlug, projectId);
// Filter by permissions and shouldRender
const filteredItems = navItems.filter((item) => {
if (!item.shouldRender) return false;
const hasAccess = allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug, project?.id ?? "");
return hasAccess;
});
// Sort by sortOrder
return filteredItems.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}, [workspaceSlug, projectId, baseNavigation, allowPermissions, project?.id]);
return navigationItems;
};
@@ -1,20 +1,20 @@
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { RefreshCcw } from "lucide-react";
import { InboxIcon, RefreshCcw } from "lucide-react";
// ui
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { InboxIssueCreateModalRoot } from "@/components/inbox/modals/create-modal";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useUserPermissions } from "@/hooks/store/user";
// plane web
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
@@ -40,10 +40,16 @@ 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() ?? ""}
featureKey={EProjectFeatureKey.INTAKE}
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Intake"
href={`/${workspaceSlug}/projects/${projectId}/intake/`}
icon={<InboxIcon className="h-4 w-4 text-custom-text-300" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
@@ -1,5 +1,10 @@
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { cn } from "@plane/utils";
import { AppRailRoot } from "@/components/navigation";
// plane web imports
import { TopNavigationRoot } from "../navigations";
export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper({
children,
@@ -7,8 +12,18 @@ export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper
children: React.ReactNode;
}) {
return (
<div className="flex relative size-full overflow-hidden bg-custom-background-90 rounded-lg transition-all ease-in-out duration-300">
<div className="size-full p-2 flex-grow transition-all ease-in-out duration-300 overflow-hidden">{children}</div>
<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 />
<div
className={cn(
"relative size-full pb-2 pr-2 flex-grow transition-all ease-in-out duration-300 overflow-hidden"
)}
>
{children}
</div>
</div>
</div>
);
});
@@ -1,24 +0,0 @@
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import { SidebarSearchButton } from "@/components/sidebar/search-button";
// hooks
import { usePowerK } from "@/hooks/store/use-power-k";
export const AppSearch = observer(function AppSearch() {
// store hooks
const { togglePowerKModal } = usePowerK();
// translation
const { t } = useTranslation();
return (
<button
type="button"
onClick={() => togglePowerKModal(true)}
aria-label={t("aria_labels.projects_sidebar.open_command_palette")}
>
<SidebarSearchButton isActive={false} />
</button>
);
});
@@ -5,6 +5,7 @@ import {
DraftIcon,
HomeIcon,
InboxIcon,
MultipleStickyIcon,
ProjectIcon,
ViewsIcon,
YourWorkIcon,
@@ -31,5 +32,7 @@ export const getSidebarNavigationItemIcon = (key: string, className: string = ""
return <DraftIcon className={cn("size-4 flex-shrink-0", className)} />;
case "archives":
return <ArchiveIcon className={cn("size-4 flex-shrink-0", className)} />;
case "stickies":
return <MultipleStickyIcon className={cn("size-4 flex-shrink-0", className)} />;
}
};
@@ -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,
});
});
@@ -91,7 +91,7 @@ export const AuthFormRoot = observer(function AuthFormRoot(props: TAuthFormRoot)
.generateUniqueCode(payload)
.then(() => ({ code: "" }))
.catch((error) => {
const errorhandler = authErrorHandler(error?.error_code.toString());
const errorhandler = authErrorHandler(error?.error_code?.toString());
if (errorhandler?.type) setErrorInfo(errorhandler);
throw error;
});
@@ -56,6 +56,6 @@ function ActiveProjectItem(props: Props) {
/>
</div>
);
};
}
export default ActiveProjectItem;
+11 -3
View File
@@ -3,19 +3,27 @@ import { observer } from "mobx-react";
// plane imports
import { Row } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { ExtendedAppHeader } from "@/plane-web/components/common/extended-app-header";
export interface AppHeaderProps {
header: ReactNode;
mobileHeader?: ReactNode;
className?: string;
rowClassName?: string;
}
export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
const { header, mobileHeader } = props;
const { header, mobileHeader, className, rowClassName } = props;
return (
<div className="z-[18]">
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
<div className={cn("z-[18]", className)}>
<Row
className={cn(
"h-11 flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100",
rowClassName
)}
>
<ExtendedAppHeader header={header} />
</Row>
{mobileHeader && mobileHeader}
@@ -77,19 +77,20 @@ export const ContentOverflowWrapper = observer(function ContentOverflowWrapper(p
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [contentRef?.current]);
}, []);
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
if (!container) return;
const handleTransitionEnd = () => {
setIsTransitioning(false);
};
containerRef.current.addEventListener("transitionend", handleTransitionEnd);
container.addEventListener("transitionend", handleTransitionEnd);
return () => {
containerRef.current?.removeEventListener("transitionend", handleTransitionEnd);
container.removeEventListener("transitionend", handleTransitionEnd);
};
}, []);
@@ -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}
/>
);
});
+1 -2
View File
@@ -4,7 +4,6 @@ import useSWR from "swr";
// plane imports
import { PRODUCT_TOUR_TRACKER_EVENTS } from "@plane/constants";
import { ContentWrapper } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { TourRoot } from "@/components/onboarding/tour";
// helpers
@@ -60,7 +59,7 @@ export const WorkspaceHomeView = observer(function WorkspaceHomeView() {
)}
<>
<HomePeekOverviewsRoot />
<ContentWrapper className={cn("gap-6 bg-custom-background-100 mx-auto scrollbar-hide px-page-x lg:px-0")}>
<ContentWrapper className="gap-6 bg-custom-background-100 mx-auto scrollbar-hide px-page-x">
<div className="max-w-[800px] mx-auto w-full">
{currentUser && <UserGreetingsView user={currentUser} />}
<DashboardWidgets />
@@ -20,7 +20,7 @@ export const DashboardQuickLinks = observer(function DashboardQuickLinks(props:
const handleCreateLinkModal = useCallback(() => {
toggleLinkModal(true);
setLinkData(undefined);
}, []);
}, [toggleLinkModal, setLinkData]);
useSWR(
workspaceSlug ? `HOME_LINKS_${workspaceSlug}` : null,
@@ -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}
@@ -124,7 +124,7 @@ export const ModuleAnalyticsSidebar = observer(function ModuleAnalyticsSidebar(p
};
const handleUpdateLink = async (formData: ModuleLink, linkId: string) => {
if (!workspaceSlug || !projectId || !module) return;
if (!workspaceSlug || !projectId) return;
const payload = { metadata: {}, ...formData };
@@ -145,7 +145,7 @@ export const ModuleAnalyticsSidebar = observer(function ModuleAnalyticsSidebar(p
};
const handleDeleteLink = async (linkId: string) => {
if (!workspaceSlug || !projectId || !module) return;
if (!workspaceSlug || !projectId) return;
deleteModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId)
.then(() => {
@@ -0,0 +1,78 @@
"use client";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { Check, SettingsIcon } from "lucide-react";
import { ContextMenu } from "@plane/propel/context-menu";
import { cn } from "@plane/utils";
// components
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";
export const AppRailRoot = observer(() => {
// router
const { workspaceSlug } = useParams();
const pathname = usePathname();
// preferences
const { preferences, updateDisplayMode } = useAppRailPreferences();
const isSettingsPath = pathname.includes(`/${workspaceSlug}/settings`);
const showLabel = preferences.displayMode === "icon_with_label";
const railWidth = showLabel ? "3.75rem" : "3rem";
return (
<div
className="h-full flex-shrink-0 transition-all ease-in-out duration-300 z-[26]"
style={{
width: railWidth,
display: "block",
}}
>
<ContextMenu>
<ContextMenu.Trigger className="h-full">
<div className="flex flex-col justify-between gap-4 px-2 py-3 h-full">
<div
className={cn("flex flex-col", {
"gap-4": showLabel,
"gap-3": !showLabel,
})}
>
<DesktopSidebarWorkspaceMenu />
<AppSidebarItemsRoot showLabel={showLabel} />
<div className="border-t border-custom-sidebar-border-300 mx-2" />
<AppSidebarItem
item={{
label: "Settings",
icon: <SettingsIcon className="size-4" />,
href: `/${workspaceSlug}/settings`,
isActive: isSettingsPath,
showLabel,
}}
/>
</div>
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content positionerClassName="z-30" className="outline-none">
<ContextMenu.Item onClick={() => updateDisplayMode("icon_only")}>
<div className="flex items-center justify-between w-full gap-2">
<span className="text-xs">Icon only</span>
{preferences.displayMode === "icon_only" && <Check className="size-3.5" />}
</div>
</ContextMenu.Item>
<ContextMenu.Item onClick={() => updateDisplayMode("icon_with_label")}>
<div className="flex items-center justify-between w-full gap-2">
<span className="text-xs">Icon with name</span>
{preferences.displayMode === "icon_with_label" && <Check className="size-3.5" />}
</div>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
</div>
);
});
@@ -0,0 +1,343 @@
import type { FC } from "react";
import { useCallback, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { GripVertical, X } from "lucide-react";
// plane imports
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Checkbox, EModalPosition, EModalWidth, ModalCore, Sortable } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
import {
usePersonalNavigationPreferences,
useProjectNavigationPreferences,
useWorkspaceNavigationPreferences,
} from "@/hooks/use-navigation-preferences";
// helpers
import { getSidebarNavigationItemIcon } from "@/plane-web/components/workspace/sidebar/helper";
// types
import type { TPersonalNavigationItemKey } from "@/types/navigation-preferences";
type TCustomizeNavigationDialogProps = {
isOpen: boolean;
onClose: () => void;
};
type TWorkspaceNavigationItem = {
key: string;
labelTranslationKey: string;
isPinned: boolean;
sortOrder: number;
};
const PERSONAL_ITEMS: Array<{ key: TPersonalNavigationItemKey; labelTranslationKey: string }> = [
{ key: "stickies", labelTranslationKey: "sidebar.stickies" },
{ key: "your_work", labelTranslationKey: "sidebar.your_work" },
{ key: "drafts", labelTranslationKey: "drafts" },
];
export const CustomizeNavigationDialog: FC<TCustomizeNavigationDialogProps> = observer((props) => {
const { isOpen, onClose } = props;
const { t } = useTranslation();
// router
const { workspaceSlug } = useParams();
// store hooks
const { allowPermissions } = useUserPermissions();
const {
preferences: personalPreferences,
togglePersonalItem,
updatePersonalItemOrder,
} = usePersonalNavigationPreferences();
const {
preferences: projectPreferences,
updateNavigationMode,
updateShowLimitedProjects,
updateLimitedProjectsCount,
} = useProjectNavigationPreferences();
const {
preferences: workspacePreferences,
toggleWorkspaceItem,
updateWorkspaceItemOrder,
} = useWorkspaceNavigationPreferences();
// local state for limited projects count input
const [projectCountInput, setProjectCountInput] = useState(projectPreferences.limitedProjectsCount.toString());
// Filter personal items by feature flags
const filteredPersonalItems = PERSONAL_ITEMS;
// Filter workspace items by permissions and feature flags, then get pinned/unpinned items
const workspaceItems = useMemo(() => {
const items = WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.filter((item) => {
// Permission check
const hasPermission = allowPermissions(
item.access,
EUserPermissionsLevel.WORKSPACE,
workspaceSlug?.toString() || ""
);
return hasPermission;
}).map((item) => {
// Get pinned status and sort order from localStorage
const preference = workspacePreferences.items[item.key];
const isPinned = preference?.is_pinned ?? false;
const sortOrder = preference?.sort_order ?? 0;
return {
key: item.key,
labelTranslationKey: item.labelTranslationKey,
isPinned,
sortOrder,
};
});
return items.sort((a, b) => a.sortOrder - b.sortOrder);
}, [workspaceSlug, allowPermissions, workspacePreferences]);
// Handle checkbox toggle
const handleWorkspaceItemToggle = useCallback(
(itemKey: string, checked: boolean) => {
toggleWorkspaceItem(itemKey, checked);
},
[toggleWorkspaceItem]
);
// Handle reorder of pinned workspace items
const handleReorder = useCallback(
(newData: TWorkspaceNavigationItem[]) => {
const itemsWithOrder = newData.map((item, index) => ({
key: item.key,
sortOrder: index,
}));
updateWorkspaceItemOrder(itemsWithOrder);
},
[updateWorkspaceItemOrder]
);
// Handle reorder of enabled personal items
const handlePersonalReorder = useCallback(
(newData: Array<{ key: TPersonalNavigationItemKey; labelTranslationKey: string }>) => {
const itemsWithOrder = newData.map((item, index) => ({
key: item.key,
sortOrder: index,
}));
updatePersonalItemOrder(itemsWithOrder);
},
[updatePersonalItemOrder]
);
// Separate personal items into enabled/disabled
const personalItems = useMemo(() => {
const items = filteredPersonalItems.map((item) => {
const itemState = personalPreferences.items[item.key];
const isEnabled = typeof itemState === "boolean" ? itemState : (itemState?.enabled ?? true);
const sortOrder = typeof itemState === "boolean" ? 0 : (itemState?.sort_order ?? 0);
return {
...item,
isEnabled,
sortOrder,
};
});
return items.sort((a, b) => a.sortOrder - b.sortOrder);
}, [personalPreferences, filteredPersonalItems]);
// Prevent typing invalid characters in number input
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Block: e, E, +, -, .
if (["e", "E", "+", "-", "."].includes(e.key)) {
e.preventDefault();
}
};
// Handle project count input change
const handleProjectCountChange = (value: string) => {
// Strip any non-digit characters
const cleanedValue = value.replace(/\D/g, "");
setProjectCountInput(cleanedValue);
// Parse and validate the value
const numValue = parseInt(cleanedValue, 10);
// If valid number, enforce minimum of 1
if (!isNaN(numValue)) {
const validValue = Math.max(1, numValue);
updateLimitedProjectsCount(validValue);
}
};
return (
<ModalCore isOpen={isOpen} handleClose={onClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<div className="flex flex-col max-h-[90vh] bg-custom-background-100 rounded-lg">
{/* Header */}
<div className="flex justify-between px-6 py-4">
<div>
<h2 className="text-xl font-semibold text-custom-text-100">{t("customize_navigation")}</h2>
<p className="mt-1 text-sm text-custom-text-300">
Selected items will always stay visible in your sidebar. You can still find the others anytime from the
More menu. These changes are personal to you and won&apos;t affect anyone else on your workspace.
</p>
</div>
<button
onClick={onClose}
className="flex-shrink-0 size-5 flex items-center justify-center rounded hover:bg-custom-background-80 text-custom-text-400"
aria-label={t("close")}
>
<X className="size-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Personal Section */}
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-custom-text-400">{t("personal")}</h3>
<div className="border border-custom-border-200 rounded-md py-2 bg-custom-background-90">
<Sortable
data={personalItems}
onChange={handlePersonalReorder}
keyExtractor={(item) => item.key}
id="personal-enabled-items"
render={(item) => (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 transition-all duration-200">
<GripVertical className="size-4 text-custom-text-400 cursor-grab active:cursor-grabbing transition-colors" />
<Checkbox
checked={!!personalPreferences.items[item.key]?.enabled}
onChange={(e) => togglePersonalItem(item.key, e.target.checked)}
/>
<div className="flex items-center gap-2 flex-1">
{getSidebarNavigationItemIcon(item.key)}
<label className="text-sm text-custom-text-200 flex-1 cursor-pointer">
{t(item.labelTranslationKey)}
</label>
</div>
</div>
)}
/>
</div>
</div>
{/* Workspace Section */}
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-custom-text-400">{t("workspace")}</h3>
<div className="border border-custom-border-200 rounded-md py-2 bg-custom-background-90">
{/* Pinned Items - Draggable */}
<Sortable
data={workspaceItems}
onChange={handleReorder}
keyExtractor={(item) => item.key}
id="workspace-pinned-items"
render={(item) => {
const icon = getSidebarNavigationItemIcon(item.key);
return (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 group transition-all duration-200">
<GripVertical className="size-4 text-custom-text-400 cursor-grab active:cursor-grabbing transition-colors" />
<Checkbox
checked={!!workspacePreferences.items[item.key]?.is_pinned}
onChange={(e) => handleWorkspaceItemToggle(item.key, e.target.checked)}
/>
<div className="flex items-center gap-2 flex-1">
{icon}
<span className="text-sm text-custom-text-200">{t(item.labelTranslationKey)}</span>
</div>
</div>
);
}}
/>
</div>
</div>
{/* Projects Section */}
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-custom-text-400">{t("projects")}</h3>
<div className="border border-custom-border-200 rounded-md px-2 py-2 bg-custom-background-90">
<div className="space-y-3">
{/* Navigation Mode Radio Buttons */}
<div className="space-y-2">
<label className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 cursor-pointer">
<input
type="radio"
name="navigation-mode"
value="accordion"
checked={projectPreferences.navigationMode === "accordion"}
onChange={() => updateNavigationMode("accordion")}
className="size-4 text-custom-primary-100 focus:ring-custom-primary-100"
/>
<div className="flex-1">
<div className="text-sm text-custom-text-200">{t("accordion_navigation_control")}</div>
<div className="text-xs text-custom-text-300">
Feature tabs will appear as nested items under project and acts as accordion.
</div>
</div>
</label>
<label className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 cursor-pointer">
<input
type="radio"
name="navigation-mode"
value="horizontal"
checked={projectPreferences.navigationMode === "horizontal"}
onChange={() => updateNavigationMode("horizontal")}
className="size-4 text-custom-primary-100 focus:ring-custom-primary-100"
/>
<div className="flex-1">
<div className="text-sm text-custom-text-200">{t("horizontal_navigation_bar")}</div>
<div className="text-xs text-custom-text-300">
Feature tabs will appear as horizontal tabs inside a project.
</div>
</div>
</label>
</div>
{/* Limited Projects Checkbox */}
<div className="space-y-2">
<label className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-custom-background-90 cursor-pointer">
<Checkbox
checked={projectPreferences.showLimitedProjects}
onChange={(e) => updateShowLimitedProjects(e.target.checked)}
/>
<span className="text-sm text-custom-text-200">{t("show_limited_projects_on_sidebar")}</span>
</label>
{projectPreferences.showLimitedProjects && (
<div className="pl-8">
<div className="flex flex-col gap-1 w-full">
<div className="flex flex-col gap-2 w-full">
<label className="text-xs text-custom-text-300 w-full">{t("enter_number_of_projects")}</label>
<input
type="number"
min="1"
step="1"
value={projectCountInput}
onKeyDown={handleKeyDown}
onChange={(e) => handleProjectCountChange(e.target.value)}
className={cn(
"w-full px-2 py-1 text-sm rounded-md",
"bg-custom-background-90 border",
"text-custom-text-200",
parseInt(projectCountInput) >= 1
? "border-custom-border-300 focus:border-custom-primary-100 focus:ring-1 focus:ring-custom-primary-100"
: "border-red-500 focus:border-red-500 focus:ring-1 focus:ring-red-500"
)}
/>
</div>
{parseInt(projectCountInput) < 1 && projectCountInput !== "" && (
<span className="text-xs text-red-500 pl-0.5">Minimum value is 1</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</ModalCore>
);
});
@@ -0,0 +1,5 @@
export * from "./app-rail-root";
export * from "./tab-navigation-root";
export * from "./top-nav-power-k";
export * from "./use-active-tab";
export * from "./use-project-actions";
@@ -0,0 +1,24 @@
// components/AppSidebarItemsRoot.tsx
"use client";
import React from "react";
import type { AppSidebarItemData } from "@/components/sidebar/sidebar-item";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { withDockItems } from "@/plane-web/components/app-rail/app-rail-hoc";
type Props = {
dockItems: (AppSidebarItemData & { shouldRender: boolean })[];
showLabel?: boolean;
};
const Component = ({ dockItems, showLabel = true }: Props) => (
<>
{dockItems
.filter((item) => item.shouldRender)
.map((item) => (
<AppSidebarItem key={item.label} item={{ ...item, showLabel }} variant="link" />
))}
</>
);
export const AppSidebarItemsRoot = withDockItems(Component);
@@ -0,0 +1,114 @@
"use client";
import type { FC } from "react";
import { useState, useRef } from "react";
import { useNavigate } from "react-router";
import { LinkIcon, LogOut, MoreHorizontal, Settings, Share2, ArchiveIcon } from "lucide-react";
// plane imports
import { MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CustomMenu } from "@plane/ui";
type Props = {
workspaceSlug: string;
project: {
id: string;
};
isAdmin: boolean;
isAuthorized: boolean;
onCopyText: () => void;
onLeaveProject: () => void;
onPublishModal: () => void;
};
export const ProjectActionsMenu: FC<Props> = ({
workspaceSlug,
project,
isAdmin,
isAuthorized,
onCopyText,
onLeaveProject,
onPublishModal,
}) => {
// states
const [isMenuActive, setIsMenuActive] = useState(false);
// translation
const { t } = useTranslation();
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
// router
const navigate = useNavigate();
return (
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="size-4" />
</span>
}
className="flex-shrink-0"
customButtonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
useCaptureForOutsideClick
closeOnSelect
onMenuClose={() => setIsMenuActive(false)}
>
{/* Publish project settings */}
{isAdmin && (
<CustomMenu.MenuItem onClick={onPublishModal}>
<div className="relative flex flex-shrink-0 items-center justify-start gap-2">
<div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded text-custom-sidebar-text-200 transition-all duration-300 hover:bg-custom-sidebar-background-80">
<Share2 className="h-3.5 w-3.5 stroke-[1.5]" />
</div>
<div>{t("publish_project")}</div>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={onCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("copy_link")}</span>
</span>
</CustomMenu.MenuItem>
{isAuthorized && (
<CustomMenu.MenuItem
onClick={() => {
navigate(`/${workspaceSlug}/projects/${project?.id}/archives/issues`);
}}
>
<div className="flex items-center justify-start gap-2 cursor-pointer">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("archives")}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() => {
navigate(`/${workspaceSlug}/settings/projects/${project?.id}`);
}}
>
<div className="flex items-center justify-start gap-2 cursor-pointer">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("settings")}</span>
</div>
</CustomMenu.MenuItem>
{/* Leave project */}
{!isAuthorized && (
<CustomMenu.MenuItem
onClick={onLeaveProject}
data-ph-element={MEMBER_TRACKER_ELEMENTS.SIDEBAR_PROJECT_QUICK_ACTIONS}
>
<div className="flex items-center justify-start gap-2">
<LogOut className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("leave_project")}</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
);
};

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