Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 202336dd9c | |||
| 8db95d9ec0 | |||
| 27bf2575bd | |||
| 22bb3c5ecc | |||
| 6b85d67f6c | |||
| 123f59e74b | |||
| c7bf912cf2 | |||
| 2980836015 | |||
| 78fbdde165 | |||
| dbc5a6348d | |||
| c685042a47 | |||
| a4de486cf7 | |||
| 3c84e75350 | |||
| 39749106a2 | |||
| 9bcb1fa469 | |||
| c31a225775 | |||
| 73c317f283 | |||
| a0da806a79 | |||
| eddf80aaed | |||
| 05b1c147a9 | |||
| ae7898aaee | |||
| 4806bdf99c | |||
| 37c59ef0d1 | |||
| 3f11183768 | |||
| d38147b875 | |||
| 3436c4f1f5 | |||
| 31e8563725 | |||
| 5ddfd0e1a9 |
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -76,5 +76,10 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.migrations": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -86,5 +86,10 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.migrations": {
|
||||
"level": "DEBUG" if DEBUG else "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 +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>
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
+10
-5
@@ -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={
|
||||
|
||||
+15
-9
@@ -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>
|
||||
|
||||
+52
@@ -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);
|
||||
+13
-6
@@ -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={
|
||||
|
||||
+14
-7
@@ -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>
|
||||
|
||||
+13
-11
@@ -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
|
||||
|
||||
+15
-13
@@ -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>
|
||||
|
||||
+12
-11
@@ -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
|
||||
|
||||
+14
-6
@@ -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,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
@@ -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 @@
|
||||
export * from "./root";
|
||||
export * from "./app-rail-hoc";
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export function AppRailRoot() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+2
-2
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const isSidebarToggleVisible = () => true;
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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'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
Reference in New Issue
Block a user