Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
def49be2d3 | ||
|
|
8b0a797906 | ||
|
|
cea6f7530b | ||
|
|
a7e2e596bf | ||
|
|
980428b204 | ||
|
|
f428c3bdaf | ||
|
|
a05cd88a53 | ||
|
|
60220801ac | ||
|
|
8db95d9ec0 | ||
|
|
27bf2575bd | ||
|
|
22bb3c5ecc | ||
|
|
6b85d67f6c | ||
|
|
123f59e74b | ||
|
|
c7bf912cf2 | ||
|
|
2980836015 | ||
|
|
78fbdde165 | ||
|
|
dbc5a6348d | ||
|
|
c685042a47 | ||
|
|
a4de486cf7 | ||
|
|
3c84e75350 | ||
|
|
39749106a2 | ||
|
|
9bcb1fa469 | ||
|
|
c31a225775 |
@@ -54,4 +54,5 @@ from .asset import (
|
||||
FileAssetSerializer,
|
||||
)
|
||||
from .invite import WorkspaceInviteSerializer
|
||||
from .member import ProjectMemberSerializer
|
||||
from .member import ProjectMemberSerializer
|
||||
from .sticky import StickySerializer
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import Sticky
|
||||
from plane.utils.content_validator import validate_html_content, validate_binary_data
|
||||
|
||||
|
||||
class StickySerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Sticky
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "owner"]
|
||||
extra_kwargs = {"name": {"required": False}}
|
||||
|
||||
def validate(self, data):
|
||||
# Validate description content for security
|
||||
if "description_html" in data and data["description_html"]:
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"error": "html content is not valid"})
|
||||
# Update the data with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
data["description_html"] = sanitized_html
|
||||
|
||||
if "description_binary" in data and data["description_binary"]:
|
||||
is_valid, error_msg = validate_binary_data(data["description_binary"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
|
||||
|
||||
return data
|
||||
@@ -9,6 +9,7 @@ from .state import urlpatterns as state_patterns
|
||||
from .user import urlpatterns as user_patterns
|
||||
from .work_item import urlpatterns as work_item_patterns
|
||||
from .invite import urlpatterns as invite_patterns
|
||||
from .sticky import urlpatterns as sticky_patterns
|
||||
|
||||
urlpatterns = [
|
||||
*asset_patterns,
|
||||
@@ -22,4 +23,5 @@ urlpatterns = [
|
||||
*user_patterns,
|
||||
*work_item_patterns,
|
||||
*invite_patterns,
|
||||
*sticky_patterns,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from plane.api.views import StickyViewSet
|
||||
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"stickies", StickyViewSet, basename="workspace-stickies")
|
||||
|
||||
urlpatterns = [
|
||||
path("workspaces/<str:slug>/", include(router.urls)),
|
||||
]
|
||||
@@ -54,4 +54,6 @@ from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpo
|
||||
|
||||
from .user import UserEndpoint
|
||||
|
||||
from .invite import WorkspaceInvitationsViewset
|
||||
from .invite import WorkspaceInvitationsViewset
|
||||
|
||||
from .sticky import StickyViewSet
|
||||
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from plane.api.views.base import BaseViewSet
|
||||
from plane.app.permissions import WorkspaceUserPermission
|
||||
from plane.db.models import Sticky, Workspace
|
||||
from plane.api.serializers import StickySerializer
|
||||
|
||||
# OpenAPI imports
|
||||
from plane.utils.openapi.decorators import sticky_docs
|
||||
|
||||
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
|
||||
from plane.utils.openapi import (
|
||||
STICKY_EXAMPLE,
|
||||
create_paginated_response,
|
||||
DELETED_RESPONSE,
|
||||
)
|
||||
|
||||
|
||||
class StickyViewSet(BaseViewSet):
|
||||
serializer_class = StickySerializer
|
||||
model = Sticky
|
||||
use_read_replica = True
|
||||
permission_classes = [WorkspaceUserPermission]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(owner_id=self.request.user.id)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="create_sticky",
|
||||
summary="Create a new sticky",
|
||||
description="Create a new sticky in the workspace",
|
||||
request=OpenApiRequest(request=StickySerializer),
|
||||
responses={
|
||||
201: OpenApiResponse(description="Sticky created", response=StickySerializer, examples=[STICKY_EXAMPLE])
|
||||
},
|
||||
)
|
||||
def create(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = StickySerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="list_stickies",
|
||||
summary="List stickies",
|
||||
description="List all stickies in the workspace",
|
||||
responses={
|
||||
200: create_paginated_response(
|
||||
StickySerializer, "Sticky", "List of stickies", example_name="List of stickies"
|
||||
)
|
||||
},
|
||||
)
|
||||
def list(self, request, slug):
|
||||
query = request.query_params.get("query", False)
|
||||
stickies = self.get_queryset().order_by("-created_at")
|
||||
if query:
|
||||
stickies = stickies.filter(description_stripped__icontains=query)
|
||||
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(stickies),
|
||||
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
|
||||
default_per_page=20,
|
||||
)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="retrieve_sticky",
|
||||
summary="Retrieve a sticky",
|
||||
description="Retrieve a sticky by its ID",
|
||||
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
|
||||
)
|
||||
def retrieve(self, request, slug, pk):
|
||||
sticky = self.get_object()
|
||||
return Response(StickySerializer(sticky).data)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="update_sticky",
|
||||
summary="Update a sticky",
|
||||
description="Update a sticky by its ID",
|
||||
request=OpenApiRequest(request=StickySerializer),
|
||||
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
|
||||
)
|
||||
def partial_update(self, request, slug, pk):
|
||||
sticky = self.get_object()
|
||||
serializer = StickySerializer(sticky, 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)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="delete_sticky",
|
||||
summary="Delete a sticky",
|
||||
description="Delete a sticky by its ID",
|
||||
responses={204: DELETED_RESPONSE},
|
||||
)
|
||||
def destroy(self, request, slug, pk):
|
||||
sticky = self.get_object()
|
||||
sticky.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -80,7 +80,7 @@ from .workspace.cycle import WorkspaceCyclesEndpoint
|
||||
from .workspace.quick_link import QuickLinkViewSet
|
||||
from .workspace.sticky import WorkspaceStickyViewSet
|
||||
|
||||
from .state.base import StateViewSet
|
||||
from .state.base import StateViewSet, IntakeStateEndpoint
|
||||
from .view.base import (
|
||||
WorkspaceViewViewSet,
|
||||
WorkspaceViewIssuesViewSet,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
# Python imports
|
||||
import boto3
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
import json
|
||||
|
||||
import boto3
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseViewSet, BaseAPIView
|
||||
from plane.app.permissions import ROLE, ProjectMemberPermission, allow_permission
|
||||
from plane.app.serializers import (
|
||||
ProjectSerializer,
|
||||
ProjectListSerializer,
|
||||
DeployBoardSerializer,
|
||||
ProjectListSerializer,
|
||||
ProjectSerializer,
|
||||
)
|
||||
|
||||
from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE
|
||||
from plane.app.views.base import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from plane.db.models import (
|
||||
UserFavorite,
|
||||
Intake,
|
||||
DeployBoard,
|
||||
Intake,
|
||||
IssueUserProperty,
|
||||
Project,
|
||||
ProjectIdentifier,
|
||||
ProjectMember,
|
||||
ProjectNetwork,
|
||||
State,
|
||||
DEFAULT_STATES,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.utils.cache import cache_response
|
||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.host import base_host
|
||||
|
||||
@@ -209,19 +211,25 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def retrieve(self, request, slug, pk):
|
||||
project = (
|
||||
self.get_queryset()
|
||||
.filter(
|
||||
project_projectmember__member=self.request.user,
|
||||
project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(archived_at__isnull=True)
|
||||
.filter(pk=pk)
|
||||
).first()
|
||||
project = self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk).first()
|
||||
|
||||
if project is None:
|
||||
return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
member_ids = [str(project_member.member_id) for project_member in project.members_list]
|
||||
|
||||
if str(request.user.id) not in member_ids:
|
||||
if project.network == ProjectNetwork.SECRET.value:
|
||||
return Response(
|
||||
{"error": "You do not have permission"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "You are not a member of this project"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
recent_visited_task.delay(
|
||||
slug=slug,
|
||||
project_id=pk,
|
||||
@@ -264,41 +272,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 +284,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
|
||||
@@ -303,7 +304,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class ProjectMemberPreferenceEndpoint(BaseAPIView):
|
||||
def get_project_member(self, slug, project_id, member_id):
|
||||
def get_queryset(self, slug, project_id, member_id):
|
||||
return ProjectMember.objects.get(
|
||||
project_id=project_id,
|
||||
member_id=member_id,
|
||||
@@ -312,25 +313,20 @@ class ProjectMemberPreferenceEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def patch(self, request, slug, project_id, member_id):
|
||||
project_member = self.get_project_member(slug, project_id, member_id)
|
||||
project_member = self.get_queryset(slug, project_id, member_id)
|
||||
|
||||
current_preferences = project_member.preferences or {}
|
||||
current_preferences["navigation"] = request.data["navigation"]
|
||||
serializer = ProjectMemberPreferenceSerializer(project_member, {"preferences": request.data}, partial=True)
|
||||
|
||||
project_member.preferences = current_preferences
|
||||
project_member.save(update_fields=["preferences"])
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
return Response({"preferences": project_member.preferences}, status=status.HTTP_200_OK)
|
||||
return Response({"preferences": serializer.data["preferences"]}, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, member_id):
|
||||
project_member = self.get_project_member(slug, project_id, member_id)
|
||||
project_member = self.get_queryset(slug, project_id, member_id)
|
||||
|
||||
response = {
|
||||
"preferences": project_member.preferences,
|
||||
"project_id": project_member.project_id,
|
||||
"member_id": project_member.member_id,
|
||||
"workspace_id": project_member.workspace_id,
|
||||
}
|
||||
serializer = ProjectMemberPreferenceSerializer(project_member)
|
||||
|
||||
return Response(response, status=status.HTTP_200_OK)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import secrets
|
||||
|
||||
# Django imports
|
||||
@@ -151,13 +149,7 @@ class UserEndpoint(BaseViewSet):
|
||||
# Include user ID to bind the code to the specific user
|
||||
cache_key = f"magic_email_update_{user.id}_{new_email}"
|
||||
## Generate a random token
|
||||
token = (
|
||||
"".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
+ "-"
|
||||
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
+ "-"
|
||||
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
)
|
||||
token = str(secrets.randbelow(900000) + 100000)
|
||||
# Store in cache with 10 minute expiration
|
||||
cache_data = json.dumps({"token": token})
|
||||
cache.set(cache_key, cache_data, timeout=600)
|
||||
|
||||
@@ -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)
|
||||
],
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Python imports
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import secrets
|
||||
|
||||
|
||||
# Module imports
|
||||
@@ -50,13 +49,7 @@ class MagicCodeProvider(CredentialAdapter):
|
||||
|
||||
def initiate(self):
|
||||
## Generate a random token
|
||||
token = (
|
||||
"".join(random.choices(string.ascii_lowercase, k=4))
|
||||
+ "-"
|
||||
+ "".join(random.choices(string.ascii_lowercase, k=4))
|
||||
+ "-"
|
||||
+ "".join(random.choices(string.ascii_lowercase, k=4))
|
||||
)
|
||||
token = str(secrets.randbelow(900000) + 100000)
|
||||
|
||||
ri = redis_instance()
|
||||
|
||||
|
||||
@@ -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,55 @@
|
||||
# Django imports
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Description
|
||||
from plane.db.models import Issue
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create Description records for existing Issue"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
batch_size = 3000
|
||||
total_processed = 0
|
||||
|
||||
self.stdout.write(self.style.NOTICE("Starting Issue to Description migration..."))
|
||||
|
||||
while True:
|
||||
issues = list(Issue.objects.filter(description_obj_id__isnull=True).order_by("created_at")[:batch_size])
|
||||
|
||||
if not issues:
|
||||
break
|
||||
|
||||
with transaction.atomic():
|
||||
descriptions = [
|
||||
Description(
|
||||
created_at=issue.created_at,
|
||||
updated_at=issue.updated_at,
|
||||
description_json=issue.description,
|
||||
description_html=issue.description_html,
|
||||
description_stripped=issue.description_stripped,
|
||||
project_id=issue.project_id,
|
||||
created_by_id=issue.created_by_id,
|
||||
updated_by_id=issue.updated_by_id,
|
||||
workspace_id=issue.workspace_id,
|
||||
)
|
||||
for issue in issues
|
||||
]
|
||||
|
||||
created_descriptions = Description.objects.bulk_create(descriptions)
|
||||
|
||||
issues_to_update = []
|
||||
for issue, description in zip(issues, created_descriptions):
|
||||
issue.description_obj_id = description.id
|
||||
issues_to_update.append(issue)
|
||||
|
||||
Issue.objects.bulk_update(issues_to_update, ["description_obj_id"])
|
||||
|
||||
total_processed += len(issues)
|
||||
self.stdout.write(self.style.SUCCESS(f"Processed {total_processed} issues..."))
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Successfully copied {total_processed} Issue records to Description table")
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
# Django imports
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Description
|
||||
from plane.db.models import Page
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create Description records for existing Page"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
batch_size = 2000
|
||||
total_processed = 0
|
||||
|
||||
self.stdout.write(self.style.NOTICE("Starting Page to Description migration..."))
|
||||
|
||||
while True:
|
||||
pages = list(Page.objects.filter(description_obj_id__isnull=True).order_by("created_at")[:batch_size])
|
||||
|
||||
if not pages:
|
||||
break
|
||||
|
||||
with transaction.atomic():
|
||||
descriptions = [
|
||||
Description(
|
||||
created_at=page.created_at,
|
||||
updated_at=page.updated_at,
|
||||
description_json=page.description,
|
||||
description_html=page.description_html,
|
||||
description_stripped=page.description_stripped,
|
||||
project_id=None, # Pages are workspace-level, not project-level
|
||||
created_by_id=page.created_by_id,
|
||||
updated_by_id=page.updated_by_id,
|
||||
workspace_id=page.workspace_id,
|
||||
)
|
||||
for page in pages
|
||||
]
|
||||
|
||||
created_descriptions = Description.objects.bulk_create(descriptions)
|
||||
|
||||
pages_to_update = []
|
||||
for page, description in zip(pages, created_descriptions):
|
||||
page.description_obj_id = description.id
|
||||
pages_to_update.append(page)
|
||||
|
||||
Page.objects.bulk_update(pages_to_update, ["description_obj_id"])
|
||||
|
||||
total_processed += len(pages)
|
||||
self.stdout.write(self.style.SUCCESS(f"Processed {total_processed} pages..."))
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Successfully copied {total_processed} Page records to Description table")
|
||||
)
|
||||
@@ -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),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.25 on 2025-12-01 10:51
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0112_auto_20251124_0603'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='issue',
|
||||
name='description_obj',
|
||||
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='issue_description', to='db.description'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='page',
|
||||
name='description_obj',
|
||||
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='page_description', to='db.description'),
|
||||
),
|
||||
]
|
||||
@@ -52,11 +52,12 @@ from .project import (
|
||||
ProjectIdentifier,
|
||||
ProjectMember,
|
||||
ProjectMemberInvite,
|
||||
ProjectNetwork,
|
||||
ProjectPublicMember,
|
||||
)
|
||||
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,13 +98,14 @@ 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)
|
||||
)
|
||||
|
||||
|
||||
class Issue(ProjectBaseModel):
|
||||
class Issue(ChangeTrackerMixin, ProjectBaseModel):
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
("high", "High"),
|
||||
@@ -138,6 +140,9 @@ class Issue(ProjectBaseModel):
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_obj = models.OneToOneField(
|
||||
"db.Description", on_delete=models.CASCADE, related_name="issue_description", null=True
|
||||
)
|
||||
priority = models.CharField(
|
||||
max_length=30,
|
||||
choices=PRIORITY_CHOICES,
|
||||
@@ -171,6 +176,8 @@ class Issue(ProjectBaseModel):
|
||||
|
||||
issue_objects = IssueManager()
|
||||
|
||||
TRACKED_FIELDS = ["description_stripped", "description", "description_html"]
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue"
|
||||
verbose_name_plural = "Issues"
|
||||
@@ -178,6 +185,12 @@ class Issue(ProjectBaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Custom save method for Issue that manages the associated Description model.
|
||||
|
||||
This method handles creation and updates of both the issue and its description in a
|
||||
single atomic transaction to ensure data consistency.
|
||||
"""
|
||||
if self.state is None:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
@@ -203,7 +216,16 @@ class Issue(ProjectBaseModel):
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if self._state.adding:
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
|
||||
is_creating = self._state.adding
|
||||
|
||||
if is_creating:
|
||||
with transaction.atomic():
|
||||
# Create a lock for this specific project using an advisory lock
|
||||
# This ensures only one transaction per project can execute this code at a time
|
||||
@@ -219,12 +241,7 @@ class Issue(ProjectBaseModel):
|
||||
largest=models.Max("sequence")
|
||||
)["largest"]
|
||||
self.sequence_id = last_sequence + 1 if last_sequence else 1
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
|
||||
largest_sort_order = Issue.objects.filter(project=self.project, state=self.state).aggregate(
|
||||
largest=models.Max("sort_order")
|
||||
)["largest"]
|
||||
@@ -234,18 +251,60 @@ class Issue(ProjectBaseModel):
|
||||
super(Issue, self).save(*args, **kwargs)
|
||||
|
||||
IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project)
|
||||
|
||||
# Create new description for new issue
|
||||
description_defaults = {
|
||||
"workspace_id": self.workspace_id,
|
||||
"project_id": self.project_id,
|
||||
"created_by_id": self.created_by_id,
|
||||
"updated_by_id": self.updated_by_id,
|
||||
"description_stripped": self.description_stripped,
|
||||
"description_json": self.description,
|
||||
"description_html": self.description_html,
|
||||
}
|
||||
description = Description.objects.create(**description_defaults)
|
||||
self.description_obj_id = description.id
|
||||
super(Issue, self).save(update_fields=["description_obj_id"])
|
||||
finally:
|
||||
# Release the lock
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key])
|
||||
else:
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(Issue, self).save(*args, **kwargs)
|
||||
with transaction.atomic():
|
||||
super(Issue, self).save(*args, **kwargs)
|
||||
|
||||
if not self.description_obj_id:
|
||||
# Create description if it doesn't exist (for existing issues)
|
||||
description_defaults = {
|
||||
"workspace_id": self.workspace_id,
|
||||
"project_id": self.project_id,
|
||||
"created_by_id": self.created_by_id,
|
||||
"updated_by_id": self.updated_by_id,
|
||||
"description_stripped": self.description_stripped,
|
||||
"description_json": self.description,
|
||||
"description_html": self.description_html,
|
||||
}
|
||||
description = Description.objects.create(**description_defaults)
|
||||
self.description_obj_id = description.id
|
||||
super(Issue, self).save(update_fields=["description_obj_id"])
|
||||
else:
|
||||
# Update description only if fields changed
|
||||
field_mapping = {
|
||||
"description_html": "description_html",
|
||||
"description_stripped": "description_stripped",
|
||||
"description": "description_json",
|
||||
}
|
||||
|
||||
changed_fields = {
|
||||
desc_field: getattr(self, issue_field)
|
||||
for issue_field, desc_field in field_mapping.items()
|
||||
if self.has_changed(issue_field)
|
||||
}
|
||||
|
||||
if changed_fields and self.description_obj_id:
|
||||
Description.objects.filter(pk=self.description_obj_id).update(
|
||||
**changed_fields, updated_by_id=self.updated_by_id, updated_at=self.updated_at
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the issue"""
|
||||
|
||||
@@ -4,19 +4,21 @@ from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
|
||||
# Module imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
from plane.db.mixins import ChangeTrackerMixin
|
||||
|
||||
from .base import BaseModel
|
||||
from .description import Description
|
||||
|
||||
|
||||
def get_view_props():
|
||||
return {"full_width": False}
|
||||
|
||||
|
||||
class Page(BaseModel):
|
||||
class Page(ChangeTrackerMixin, BaseModel):
|
||||
PRIVATE_ACCESS = 1
|
||||
PUBLIC_ACCESS = 0
|
||||
DEFAULT_SORT_ORDER = 65535
|
||||
@@ -29,6 +31,9 @@ class Page(BaseModel):
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_obj = models.OneToOneField(
|
||||
"db.Description", on_delete=models.CASCADE, related_name="page_description", null=True
|
||||
)
|
||||
owned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pages")
|
||||
access = models.PositiveSmallIntegerField(choices=((0, "Public"), (1, "Private")), default=0)
|
||||
color = models.CharField(max_length=255, blank=True)
|
||||
@@ -53,6 +58,8 @@ class Page(BaseModel):
|
||||
external_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
TRACKED_FIELDS = ["description_stripped", "description", "description_html"]
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page"
|
||||
verbose_name_plural = "Pages"
|
||||
@@ -64,13 +71,58 @@ class Page(BaseModel):
|
||||
return f"{self.owned_by.email} <{self.name}>"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Custom save method for Page that manages the associated Description model.
|
||||
|
||||
This method handles creation and updates of both the page and its description in a
|
||||
single atomic transaction to ensure data consistency.
|
||||
"""
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(Page, self).save(*args, **kwargs)
|
||||
|
||||
is_creating = self._state.adding
|
||||
|
||||
# Prepare description defaults
|
||||
description_defaults = {
|
||||
"workspace_id": self.workspace_id,
|
||||
"project_id": None,
|
||||
"created_by_id": self.created_by_id,
|
||||
"updated_by_id": self.updated_by_id,
|
||||
"description_stripped": self.description_stripped,
|
||||
"description_json": self.description,
|
||||
"description_html": self.description_html,
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
super(Page, self).save(*args, **kwargs)
|
||||
|
||||
if is_creating or not self.description_obj_id:
|
||||
# Create new description for new page
|
||||
description = Description.objects.create(**description_defaults)
|
||||
self.description_obj_id = description.id
|
||||
super(Page, self).save(update_fields=["description_obj_id"])
|
||||
else:
|
||||
# Update description only if fields changed
|
||||
field_mapping = {
|
||||
"description_html": "description_html",
|
||||
"description_stripped": "description_stripped",
|
||||
"description": "description_json",
|
||||
}
|
||||
|
||||
changed_fields = {
|
||||
desc_field: getattr(self, page_field)
|
||||
for page_field, desc_field in field_mapping.items()
|
||||
if self.has_changed(page_field)
|
||||
}
|
||||
|
||||
if changed_fields and self.description_obj_id:
|
||||
Description.objects.filter(pk=self.description_obj_id).update(
|
||||
**changed_fields, updated_by_id=self.updated_by_id, updated_at=self.updated_at
|
||||
)
|
||||
|
||||
|
||||
class PageLog(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}>"
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -140,6 +140,7 @@ from .examples import (
|
||||
WORKSPACE_MEMBER_EXAMPLE,
|
||||
PROJECT_MEMBER_EXAMPLE,
|
||||
CYCLE_ISSUE_EXAMPLE,
|
||||
STICKY_EXAMPLE,
|
||||
)
|
||||
|
||||
# Helper decorators
|
||||
@@ -292,6 +293,7 @@ __all__ = [
|
||||
"WORKSPACE_MEMBER_EXAMPLE",
|
||||
"PROJECT_MEMBER_EXAMPLE",
|
||||
"CYCLE_ISSUE_EXAMPLE",
|
||||
"STICKY_EXAMPLE",
|
||||
# Decorators
|
||||
"workspace_docs",
|
||||
"project_docs",
|
||||
|
||||
@@ -262,3 +262,18 @@ def state_docs(**kwargs):
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
def sticky_docs(**kwargs):
|
||||
"""Decorator for sticky management endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Stickies"],
|
||||
"summary": "Endpoints for sticky create/update/delete and fetch sticky details",
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
@@ -672,6 +672,15 @@ CYCLE_ISSUE_EXAMPLE = OpenApiExample(
|
||||
},
|
||||
)
|
||||
|
||||
STICKY_EXAMPLE = OpenApiExample(
|
||||
name="Sticky",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Sticky 1",
|
||||
"description_html": "<p>Sticky 1 description</p>",
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Sample data for different entity types
|
||||
SAMPLE_ISSUE = {
|
||||
@@ -781,6 +790,13 @@ SAMPLE_CYCLE_ISSUE = {
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
}
|
||||
|
||||
SAMPLE_STICKY = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Sticky 1",
|
||||
"description_html": "<p>Sticky 1 description</p>",
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
}
|
||||
|
||||
# Mapping of schema types to sample data
|
||||
SCHEMA_EXAMPLES = {
|
||||
"Issue": SAMPLE_ISSUE,
|
||||
@@ -795,6 +811,7 @@ SCHEMA_EXAMPLES = {
|
||||
"Activity": SAMPLE_ACTIVITY,
|
||||
"Intake": SAMPLE_INTAKE,
|
||||
"CycleIssue": SAMPLE_CYCLE_ISSUE,
|
||||
"Sticky": SAMPLE_STICKY,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export function AuthUniqueCodeForm(props: TAuthUniqueCodeForm) {
|
||||
name="code"
|
||||
value={uniqueCodeFormData.code}
|
||||
onChange={(e) => handleFormChange("code", e.target.value)}
|
||||
placeholder="gets-sets-flys"
|
||||
placeholder="123456"
|
||||
className="disable-autofill-style h-10 w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -2,9 +2,13 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { Header, Row } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { AppHeader } from "@/components/core/app-header";
|
||||
import { TabNavigationRoot } from "@/components/navigation";
|
||||
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
|
||||
// local components
|
||||
@@ -14,6 +18,7 @@ export const ProjectWorkItemDetailsHeader = observer(function ProjectWorkItemDet
|
||||
// router
|
||||
const { workspaceSlug, workItem } = useParams();
|
||||
// store hooks
|
||||
const { sidebarCollapsed } = useAppTheme();
|
||||
const {
|
||||
issue: { getIssueById, getIssueIdByIdentifier },
|
||||
} = useIssueDetail();
|
||||
@@ -29,8 +34,13 @@ export const ProjectWorkItemDetailsHeader = observer(function ProjectWorkItemDet
|
||||
<div className="z-20">
|
||||
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
|
||||
<div className="flex items-center gap-2 divide-x divide-custom-border-100 h-full w-full">
|
||||
<div className="flex items-center h-full w-full flex-1">
|
||||
<Header className="h-full">
|
||||
<div className="flex items-center gap-2 size-full flex-1">
|
||||
{sidebarCollapsed && (
|
||||
<div className="shrink-0">
|
||||
<AppSidebarToggleButton />
|
||||
</div>
|
||||
)}
|
||||
<Header className={cn("h-full", { "pl-1.5": !sidebarCollapsed })}>
|
||||
<Header.LeftItem className="h-full max-w-full">
|
||||
<TabNavigationRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
|
||||
@@ -82,7 +82,7 @@ function IssueDetailsPage({ params }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{error ? (
|
||||
{error && !issueLoader ? (
|
||||
<EmptyState
|
||||
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
|
||||
title={t("issue.empty_state.issue_detail.title")}
|
||||
|
||||
@@ -13,6 +13,8 @@ import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web imports
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const WorkItemDetailsHeader = observer(() => {
|
||||
// router
|
||||
@@ -34,6 +36,7 @@ export const WorkItemDetailsHeader = observer(() => {
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
@@ -76,7 +76,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
const handleClose = () => toggleExtendedProjectSidebar(false);
|
||||
const handleClose = useCallback(() => toggleExtendedProjectSidebar(false), [toggleExtendedProjectSidebar]);
|
||||
|
||||
const handleCopyText = (projectId: string) => {
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
|
||||
@@ -102,8 +102,9 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
|
||||
extendedSidebarRef={extendedProjectSidebarRef}
|
||||
handleClose={handleClose}
|
||||
excludedElementId="extended-project-sidebar-toggle"
|
||||
className="px-0"
|
||||
>
|
||||
<div className="flex flex-col gap-1 w-full sticky top-4 pt-0">
|
||||
<div className="flex flex-col gap-1 w-full sticky top-4 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-custom-text-300 py-1.5">Projects</span>
|
||||
{isAuthorizedUser && (
|
||||
@@ -111,7 +112,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
|
||||
<button
|
||||
type="button"
|
||||
data-ph-element={PROJECT_TRACKER_ELEMENTS.EXTENDED_SIDEBAR_ADD_BUTTON}
|
||||
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
|
||||
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0 text-custom-text-300 hover:text-custom-text-200 transition-colors"
|
||||
onClick={() => {
|
||||
setIsProjectModalOpen(true);
|
||||
}}
|
||||
@@ -133,7 +134,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
|
||||
</div>
|
||||
</div>
|
||||
{filteredProjects.length === 0 ? (
|
||||
<div className="flex flex-col items-center mt-4 px-6 pt-10">
|
||||
<div className="flex flex-col items-center mt-4 p-10">
|
||||
<EmptyStateCompact
|
||||
title={t("common_empty_state.search.title")}
|
||||
description={t("common_empty_state.search.description")}
|
||||
@@ -143,7 +144,7 @@ export const ExtendedProjectSidebar = observer(function ExtendedProjectSidebar()
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 pl-4">
|
||||
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 pl-9 pr-2">
|
||||
{filteredProjects.map((projectId, index) => (
|
||||
<SidebarProjectsListItem
|
||||
key={projectId}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EXTENDED_SIDEBAR_WIDTH, SIDEBAR_WIDTH } from "@plane/constants";
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
// hooks
|
||||
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
extendedSidebarRef: React.RefObject<HTMLDivElement>;
|
||||
isExtendedSidebarOpened: boolean;
|
||||
@@ -17,26 +19,35 @@ type Props = {
|
||||
};
|
||||
|
||||
export const ExtendedSidebarWrapper = observer(function ExtendedSidebarWrapper(props: Props) {
|
||||
const { children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props;
|
||||
const { className, children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props;
|
||||
// store hooks
|
||||
const { sidebarCollapsed } = useAppTheme();
|
||||
// local storage
|
||||
const { storedValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH);
|
||||
|
||||
useExtendedSidebarOutsideClickDetector(extendedSidebarRef, handleClose, excludedElementId);
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarCollapsed) {
|
||||
handleClose();
|
||||
}
|
||||
}, [sidebarCollapsed, handleClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={excludedElementId}
|
||||
ref={extendedSidebarRef}
|
||||
className={cn(
|
||||
`absolute h-full z-[21] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm`,
|
||||
"absolute h-full z-[21] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm",
|
||||
{
|
||||
"translate-x-0 opacity-100": isExtendedSidebarOpened,
|
||||
[`-translate-x-[${EXTENDED_SIDEBAR_WIDTH}px] opacity-0 hidden`]: !isExtendedSidebarOpened,
|
||||
}
|
||||
"opacity-100": isExtendedSidebarOpened,
|
||||
"opacity-0 hidden": !isExtendedSidebarOpened,
|
||||
},
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
left: `${storedValue ?? SIDEBAR_WIDTH}px`,
|
||||
width: `${isExtendedSidebarOpened ? EXTENDED_SIDEBAR_WIDTH : 0}px`,
|
||||
width: `${EXTENDED_SIDEBAR_WIDTH}px`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
@@ -107,7 +107,7 @@ export const ExtendedAppSidebar = observer(function ExtendedAppSidebar() {
|
||||
if (updatedSortOrder != undefined) updateWorkspaceItemSortOrder(sourceId, updatedSortOrder);
|
||||
};
|
||||
|
||||
const handleClose = () => toggleExtendedSidebar(false);
|
||||
const handleClose = useCallback(() => toggleExtendedSidebar(false), [toggleExtendedSidebar]);
|
||||
|
||||
return (
|
||||
<ExtendedSidebarWrapper
|
||||
|
||||
+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
|
||||
|
||||
+2
@@ -41,6 +41,7 @@ import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// plane web imports
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const CycleIssuesHeader = observer(function CycleIssuesHeader() {
|
||||
// refs
|
||||
@@ -134,6 +135,7 @@ export const CycleIssuesHeader = observer(function CycleIssuesHeader() {
|
||||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
|
||||
+4
-2
@@ -1,4 +1,3 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
@@ -15,11 +14,13 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web imports
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const CyclesListHeader = observer(function CyclesListHeader() {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
|
||||
// store hooks
|
||||
const { toggleCreateCycleModal } = useCommandPalette();
|
||||
@@ -36,6 +37,7 @@ export const CyclesListHeader = observer(function CyclesListHeader() {
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
|
||||
+25
-5
@@ -1,14 +1,25 @@
|
||||
"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";
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
// local imports
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
export default function ProjectLayout({ params }: Route.ComponentProps) {
|
||||
function ProjectLayout({ params }: Route.ComponentProps) {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// store hooks
|
||||
const { sidebarCollapsed } = useAppTheme();
|
||||
// preferences
|
||||
const { preferences: projectPreferences } = useProjectNavigationPreferences();
|
||||
|
||||
@@ -18,9 +29,14 @@ export default function ProjectLayout({ params }: Route.ComponentProps) {
|
||||
<div className="z-20">
|
||||
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
|
||||
<div className="flex items-center gap-2 divide-x divide-custom-border-100 h-full w-full">
|
||||
<div className="flex items-center h-full w-full flex-1">
|
||||
<Header className="h-full">
|
||||
<Header.LeftItem className="h-full max-w-full">
|
||||
<div className="flex items-center gap-2 size-full flex-1">
|
||||
{sidebarCollapsed && (
|
||||
<div className="shrink-0">
|
||||
<AppSidebarToggleButton />
|
||||
</div>
|
||||
)}
|
||||
<Header className={cn("h-full", { "pl-1.5": !sidebarCollapsed })}>
|
||||
<Header.LeftItem className="h-full max-w-full flex items-center gap-2">
|
||||
<TabNavigationRoot workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
</Header.LeftItem>
|
||||
</Header>
|
||||
@@ -29,7 +45,11 @@ export default function ProjectLayout({ params }: Route.ComponentProps) {
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
<Outlet />
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
|
||||
<Outlet />
|
||||
</ProjectAuthWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProjectLayout);
|
||||
|
||||
+3
@@ -40,6 +40,8 @@ import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web imports
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() {
|
||||
// refs
|
||||
@@ -126,6 +128,7 @@ export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() {
|
||||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
|
||||
+3
@@ -15,6 +15,8 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web imports
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const ModulesListHeader = observer(function ModulesListHeader() {
|
||||
// router
|
||||
@@ -39,6 +41,7 @@ export const ModulesListHeader = observer(function ModulesListHeader() {
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
|
||||
+5
-6
@@ -1,23 +1,21 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { PageIcon } from "@plane/propel/icons";
|
||||
// types
|
||||
import type { ICustomSearchSelectOption } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
// components
|
||||
import { getPageName } from "@plane/utils";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { PageAccessIcon } from "@/components/common/page-access-icon";
|
||||
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
|
||||
import { PageHeaderActions } from "@/components/pages/header/actions";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
// plane web components
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web imports
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
|
||||
|
||||
export interface IPagesHeaderProps {
|
||||
@@ -64,6 +62,7 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
|
||||
+4
-2
@@ -15,7 +15,8 @@ import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
// plane web hooks
|
||||
// plane web imports
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
|
||||
|
||||
export const PagesListHeader = observer(function PagesListHeader() {
|
||||
@@ -23,7 +24,7 @@ export const PagesListHeader = observer(function PagesListHeader() {
|
||||
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const pageType = searchParams.get("type");
|
||||
// store hooks
|
||||
@@ -69,6 +70,7 @@ export const PagesListHeader = observer(function PagesListHeader() {
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
|
||||
+4
-5
@@ -2,7 +2,7 @@ import { useCallback, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Lock } from "lucide-react";
|
||||
// plane constants
|
||||
// plane imports
|
||||
import {
|
||||
EIssueFilterType,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
@@ -10,19 +10,16 @@ import {
|
||||
EUserPermissionsLevel,
|
||||
WORK_ITEM_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
// types
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { ViewsIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { EIssuesStoreType, EViewAccess, EIssueLayoutTypes } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters";
|
||||
// constants
|
||||
import { ViewQuickActions } from "@/components/views/quick-actions";
|
||||
import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle";
|
||||
// hooks
|
||||
@@ -31,8 +28,9 @@ import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web imports
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const ProjectViewIssuesHeader = observer(function ProjectViewIssuesHeader() {
|
||||
// refs
|
||||
@@ -120,6 +118,7 @@ export const ProjectViewIssuesHeader = observer(function ProjectViewIssuesHeader
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
|
||||
+3
@@ -11,6 +11,8 @@ import { ViewListHeader } from "@/components/views/view-list-header";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
// plane web imports
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const ProjectViewsHeader = observer(function ProjectViewsHeader() {
|
||||
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
|
||||
@@ -23,6 +25,7 @@ export const ProjectViewsHeader = observer(function ProjectViewsHeader() {
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Outlet } from "react-router";
|
||||
// plane web layouts
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
export default function ProjectDetailLayout({ params }: Route.ComponentProps) {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = params;
|
||||
return (
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
|
||||
<Outlet />
|
||||
</ProjectAuthWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProfileForm } from "@/components/profile/form";
|
||||
// hooks
|
||||
@@ -12,13 +11,7 @@ function ProfileSettingsPage() {
|
||||
// store hooks
|
||||
const { data: currentUser, userProfile } = useUser();
|
||||
|
||||
if (!currentUser)
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!currentUser) return <></>;
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
|
||||
|
||||
+13
-21
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { PreferencesList } from "@/components/preferences/list";
|
||||
import { LanguageTimezone } from "@/components/profile/preferences/language-timezone";
|
||||
@@ -16,30 +15,23 @@ function ProfileAppearancePage() {
|
||||
// hooks
|
||||
const { data: userProfile } = useUserProfile();
|
||||
|
||||
if (!userProfile) return <></>;
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("preferences")}`} />
|
||||
{userProfile ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div>
|
||||
<SettingsHeading
|
||||
title={t("account_settings.preferences.heading")}
|
||||
description={t("account_settings.preferences.description")}
|
||||
/>
|
||||
<PreferencesList />
|
||||
</div>
|
||||
<div>
|
||||
<ProfileSettingContentHeader title={t("language_and_time")} />
|
||||
<LanguageTimezone />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div>
|
||||
<SettingsHeading
|
||||
title={t("account_settings.preferences.heading")}
|
||||
description={t("account_settings.preferences.description")}
|
||||
/>
|
||||
<PreferencesList />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<ProfileSettingContentHeader title={t("language_and_time")} />
|
||||
<LanguageTimezone />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Outlet } from "react-router";
|
||||
// components
|
||||
import { getProjectActivePath } from "@/components/settings/helper";
|
||||
import { SettingsMobileNav } from "@/components/settings/mobile";
|
||||
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
|
||||
// plane web imports
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
// types
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsMobileNav hamburgerContent={ProjectSettingsSidebar} activePath={getProjectActivePath(pathname) || ""} />
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className="hidden md:block">{projectId && <ProjectSettingsSidebar />}</div>
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
|
||||
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
|
||||
<Outlet />
|
||||
</div>
|
||||
</ProjectAuthWrapper>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProjectDetailSettingsLayout);
|
||||
+2
-7
@@ -1,6 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
// components
|
||||
@@ -24,12 +23,8 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// store hooks
|
||||
const { currentProjectDetails, fetchProjectDetails } = useProject();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// api call to fetch project details
|
||||
// TODO: removed this API if not necessary
|
||||
const { isLoading } = useSWR(`PROJECT_DETAILS_${projectId}`, () => fetchProjectDetails(workspaceSlug, projectId));
|
||||
// derived values
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
|
||||
|
||||
@@ -56,7 +51,7 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) {
|
||||
)}
|
||||
|
||||
<div className={`w-full ${isAdmin ? "" : "opacity-60"}`}>
|
||||
{currentProjectDetails && !isLoading ? (
|
||||
{currentProjectDetails ? (
|
||||
<ProjectDetailsForm
|
||||
project={currentProjectDetails}
|
||||
workspaceSlug={workspaceSlug}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Outlet } from "react-router";
|
||||
// components
|
||||
import { getProjectActivePath } from "@/components/settings/helper";
|
||||
import { SettingsMobileNav } from "@/components/settings/mobile";
|
||||
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
// types
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
function ProjectSettingsLayout({ params }: Route.ComponentProps) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// store hooks
|
||||
const { joinedProjectIds } = useProject();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,19 +21,7 @@ function ProjectSettingsLayout({ params }: Route.ComponentProps) {
|
||||
}
|
||||
}, [joinedProjectIds, router, workspaceSlug, projectId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsMobileNav hamburgerContent={ProjectSettingsSidebar} activePath={getProjectActivePath(pathname) || ""} />
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className="hidden md:block">{projectId && <ProjectSettingsSidebar />}</div>
|
||||
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</ProjectAuthWrapper>
|
||||
</>
|
||||
);
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export default observer(ProjectSettingsLayout);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { cn } from "@plane/utils";
|
||||
// assets
|
||||
import ProjectDarkEmptyState from "@/app/assets/empty-state/project-settings/no-projects-dark.png?url";
|
||||
import ProjectLightEmptyState from "@/app/assets/empty-state/project-settings/no-projects-light.png?url";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
|
||||
function ProjectSettingsPage() {
|
||||
@@ -10,13 +16,10 @@ function ProjectSettingsPage() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
// derived values
|
||||
const resolvedPath =
|
||||
resolvedTheme === "dark"
|
||||
? "/empty-state/project-settings/no-projects-dark.png"
|
||||
: "/empty-state/project-settings/no-projects-light.png";
|
||||
const resolvedPath = resolvedTheme === "dark" ? ProjectDarkEmptyState : ProjectLightEmptyState;
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center justify-center h-full max-w-[480px] mx-auto">
|
||||
<img src={resolvedPath} className="w-full h-full object-contain" alt="No projects yet" />
|
||||
<img src={resolvedPath} alt="No projects yet" />
|
||||
<div className="text-lg font-semibold text-custom-text-350">No projects yet</div>
|
||||
<div className="text-sm text-custom-text-350 text-center">
|
||||
Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you
|
||||
@@ -38,4 +41,4 @@ function ProjectSettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectSettingsPage;
|
||||
export default observer(ProjectSettingsPage);
|
||||
|
||||
+149
-156
@@ -108,9 +108,13 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
),
|
||||
]),
|
||||
|
||||
// ====================================================================
|
||||
// PROJECT LEVEL ROUTES
|
||||
// ====================================================================
|
||||
// Archived Projects
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/archives",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// PROJECT LEVEL ROUTES
|
||||
@@ -122,136 +126,123 @@ 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
|
||||
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/: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(
|
||||
":workspaceSlug/projects/archives",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx"
|
||||
":workspaceSlug/projects/:projectId/issues",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Archives - Issues, Cycles, Modules
|
||||
// Project Archives - Issues - List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/issues",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Archives - Issues - Detail
|
||||
layout(
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx",
|
||||
[
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/issues/:archivedIssueId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx"
|
||||
),
|
||||
]
|
||||
// Issue Detail
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/issues/:issueId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
|
||||
),
|
||||
|
||||
// Project Archives - Cycles
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx", [
|
||||
// Cycle Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/cycles",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx"
|
||||
":workspaceSlug/projects/:projectId/cycles/:cycleId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Archives - Modules
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx", [
|
||||
// Cycles List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/modules",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx"
|
||||
":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"
|
||||
),
|
||||
]),
|
||||
]),
|
||||
|
||||
// Project Archives - Issues, Cycles, Modules
|
||||
// Project Archives - Issues - List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/issues",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Archives - Issues - Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/issues/:archivedIssueId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Archives - Cycles
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/cycles",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Archives - Modules
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/modules",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx"
|
||||
),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -320,44 +311,46 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx", [
|
||||
// CORE Routes
|
||||
// Project Settings
|
||||
// No Projects available page
|
||||
route(":workspaceSlug/settings/projects", "./(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx"),
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx"
|
||||
),
|
||||
// Project Members
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/members",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx"
|
||||
),
|
||||
// Project Features
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/features",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx"
|
||||
),
|
||||
// Project States
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/states",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx"
|
||||
),
|
||||
// Project Labels
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/labels",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx"
|
||||
),
|
||||
// Project Estimates
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/estimates",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx"
|
||||
),
|
||||
// Project Automations
|
||||
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx", [
|
||||
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx", [
|
||||
// Project Settings
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/automations",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx"
|
||||
":workspaceSlug/settings/projects/:projectId",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx"
|
||||
),
|
||||
// Project Members
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/members",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx"
|
||||
),
|
||||
// Project Features
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/features",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx"
|
||||
),
|
||||
// Project States
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/states",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx"
|
||||
),
|
||||
// Project Labels
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/labels",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx"
|
||||
),
|
||||
// Project Estimates
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/estimates",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx"
|
||||
),
|
||||
// Project Automations
|
||||
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/automations",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx"
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// local components
|
||||
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
|
||||
import { ProjectBreadcrumb } from "./project";
|
||||
|
||||
type TCommonProjectBreadcrumbProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export function CommonProjectBreadcrumbs(props: TCommonProjectBreadcrumbProps) {
|
||||
const { workspaceSlug, projectId } = props;
|
||||
// preferences
|
||||
const { preferences: projectPreferences } = useProjectNavigationPreferences();
|
||||
|
||||
if (projectPreferences.navigationMode === "horizontal") return null;
|
||||
return <ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />;
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TPro
|
||||
|
||||
// helpers
|
||||
const renderIcon = (projectDetails: TProject) => (
|
||||
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
|
||||
<span className="grid place-items-center flex-shrink-0 size-4">
|
||||
<Logo logo={projectDetails.logo_props} size={14} />
|
||||
</span>
|
||||
);
|
||||
|
||||
+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;
|
||||
|
||||
@@ -64,7 +64,7 @@ export const CommentBlock = observer(function CommentBlock(props: TCommentBlock)
|
||||
position="bottom"
|
||||
>
|
||||
<span className="text-custom-text-350">
|
||||
{calculateTimeAgo(comment.updated_at)}
|
||||
{calculateTimeAgo(comment.created_at)}
|
||||
{comment.edited_at && ` (${t("edited")})`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "react-router";
|
||||
// components
|
||||
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
|
||||
|
||||
export const ExtendedAppHeader = observer(function ExtendedAppHeader(props: { header: ReactNode }) {
|
||||
const { header } = props;
|
||||
// params
|
||||
const { projectId, workItem } = useParams();
|
||||
// preferences
|
||||
const { preferences: projectPreferences } = useProjectNavigationPreferences();
|
||||
// store hooks
|
||||
const { sidebarCollapsed } = useAppTheme();
|
||||
// derived values
|
||||
const shouldShowSidebarToggleButton = projectPreferences.navigationMode === "accordion" || (!projectId && !workItem);
|
||||
|
||||
return (
|
||||
<>
|
||||
{sidebarCollapsed && <AppSidebarToggleButton />}
|
||||
{sidebarCollapsed && shouldShowSidebarToggleButton && <AppSidebarToggleButton />}
|
||||
<div className="w-full">{header}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web imports
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const IssuesHeader = observer(function IssuesHeader() {
|
||||
// router
|
||||
@@ -61,6 +63,7 @@ export const IssuesHeader = observer(function IssuesHeader() {
|
||||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"} className="flex-grow-0">
|
||||
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
|
||||
@@ -20,7 +20,7 @@ export const TopNavigationRoot = observer(() => {
|
||||
>
|
||||
{/* Workspace Menu */}
|
||||
<div className="shrink-0 flex-1">
|
||||
<WorkspaceMenuRoot />
|
||||
<WorkspaceMenuRoot variant="top-navigation" />
|
||||
</div>
|
||||
{/* Power K Search */}
|
||||
<div className="shrink-0">
|
||||
|
||||
+8
-8
@@ -18,7 +18,7 @@ import { useUser } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { TourSidebar } from "./sidebar";
|
||||
|
||||
type Props = {
|
||||
export type TOnboardingTourProps = {
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ const TOUR_STEPS: {
|
||||
key: TTourSteps;
|
||||
title: string;
|
||||
description: string;
|
||||
image: any;
|
||||
image: string;
|
||||
prevStep?: TTourSteps;
|
||||
nextStep?: TTourSteps;
|
||||
}[] = [
|
||||
@@ -75,7 +75,7 @@ const TOUR_STEPS: {
|
||||
},
|
||||
];
|
||||
|
||||
export const TourRoot = observer(function TourRoot(props: Props) {
|
||||
export const TourRoot = observer(function TourRoot(props: TOnboardingTourProps) {
|
||||
const { onComplete } = props;
|
||||
// states
|
||||
const [step, setStep] = useState<TTourSteps>("welcome");
|
||||
@@ -89,12 +89,12 @@ export const TourRoot = observer(function TourRoot(props: Props) {
|
||||
return (
|
||||
<>
|
||||
{step === "welcome" ? (
|
||||
<div className="h-3/4 w-4/5 overflow-hidden rounded-[10px] bg-custom-background-100 md:w-1/2 lg:w-2/5">
|
||||
<div className="w-4/5 overflow-hidden rounded-[10px] bg-custom-background-100 md:w-1/2 lg:w-2/5">
|
||||
<div className="h-full overflow-hidden">
|
||||
<div className="grid h-3/5 place-items-center bg-custom-primary-100">
|
||||
<PlaneLockup className="h-10 w-auto text-custom-text-100" />
|
||||
<div className="grid h-64 place-items-center bg-custom-primary-100">
|
||||
<PlaneLockup className="h-10 w-auto text-white" />
|
||||
</div>
|
||||
<div className="flex h-2/5 flex-col overflow-y-auto p-6">
|
||||
<div className="flex flex-col overflow-y-auto p-6">
|
||||
<h3 className="font-semibold sm:text-xl">
|
||||
Welcome to Plane, {currentUser?.first_name} {currentUser?.last_name}
|
||||
</h3>
|
||||
@@ -103,7 +103,7 @@ export const TourRoot = observer(function TourRoot(props: Props) {
|
||||
started by creating a project.
|
||||
</p>
|
||||
<div className="flex h-full items-end">
|
||||
<div className="mt-8 flex items-center gap-6">
|
||||
<div className="mt-12 flex items-center gap-6">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
// icons
|
||||
// plane imports
|
||||
import { CycleIcon, ModuleIcon, PageIcon, ViewsIcon, WorkItemsIcon } from "@plane/propel/icons";
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
// types
|
||||
import type { TTourSteps } from "./root";
|
||||
|
||||
const sidebarOptions: {
|
||||
key: TTourSteps;
|
||||
label: string;
|
||||
Icon: any;
|
||||
Icon: React.FC<ISvgIcons>;
|
||||
}[] = [
|
||||
{
|
||||
key: "work-items",
|
||||
@@ -14,6 +14,8 @@ import { InboxIssueCreateModalRoot } from "@/components/inbox/modals/create-moda
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
|
||||
// states
|
||||
@@ -38,6 +40,7 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
|
||||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-4 flex-grow">
|
||||
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
|
||||
@@ -12,7 +12,7 @@ export const WorkspaceContentWrapper = observer(function WorkspaceContentWrapper
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col relative size-full overflow-hidden bg-custom-background-90 rounded-lg transition-all ease-in-out duration-300">
|
||||
<div className="flex flex-col relative size-full overflow-hidden bg-custom-background-90 transition-all ease-in-out duration-300">
|
||||
<TopNavigationRoot />
|
||||
<div className="relative flex size-full overflow-hidden">
|
||||
<AppRailRoot />
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// layouts
|
||||
import { ProjectAuthWrapper as CoreProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper";
|
||||
|
||||
export type IProjectAuthWrapper = {
|
||||
workspaceSlug: string;
|
||||
projectId?: string;
|
||||
projectId: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
// assets
|
||||
import Unauthorized from "@/app/assets/auth/unauthorized.svg?url";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
||||
type Props = {
|
||||
projectId?: string;
|
||||
isPrivateProject?: boolean;
|
||||
};
|
||||
|
||||
export function JoinProject(props: Props) {
|
||||
const { projectId, isPrivateProject = false } = props;
|
||||
// states
|
||||
const [isJoiningProject, setIsJoiningProject] = useState(false);
|
||||
// store hooks
|
||||
const { joinProject } = useUserPermissions();
|
||||
const { fetchProjectDetails } = useProject();
|
||||
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
const handleJoin = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setIsJoiningProject(true);
|
||||
|
||||
joinProject(workspaceSlug.toString(), projectId.toString())
|
||||
.then(() => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()))
|
||||
.finally(() => setIsJoiningProject(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-custom-background-100 text-center">
|
||||
<div className="h-44 w-72">
|
||||
<img src={Unauthorized} className="h-[176px] w-[288px] object-contain" alt="JoinProject" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-custom-text-100">
|
||||
{!isPrivateProject ? `You are not a member of this project yet.` : `You are not a member of this project.`}
|
||||
</h1>
|
||||
|
||||
<div className="w-full max-w-md text-base text-custom-text-200">
|
||||
<p className="mx-auto w-full text-sm md:w-3/4">
|
||||
{!isPrivateProject
|
||||
? `Click the button below to join it.`
|
||||
: `This is a private project. \n We can't tell you more about this project to protect confidentiality.`}
|
||||
</p>
|
||||
</div>
|
||||
{!isPrivateProject && (
|
||||
<div>
|
||||
<Button
|
||||
variant="primary"
|
||||
prependIcon={<ClipboardList color="white" />}
|
||||
loading={isJoiningProject}
|
||||
onClick={handleJoin}
|
||||
>
|
||||
{isJoiningProject ? "Taking you in" : "Click to join"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmptyStateDetailed } from "@plane/propel/empty-state";
|
||||
|
||||
type TProps = {
|
||||
isWorkspaceAdmin: boolean;
|
||||
handleJoinProject: () => void;
|
||||
isJoinButtonDisabled: boolean;
|
||||
errorStatusCode: number | undefined;
|
||||
};
|
||||
|
||||
export const ProjectAccessRestriction = observer(function ProjectAccessRestriction(props: TProps) {
|
||||
const { isWorkspaceAdmin, handleJoinProject, isJoinButtonDisabled, errorStatusCode } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Show join project screen if:
|
||||
// - User lacks project membership (409 Conflict)
|
||||
// - User lacks permission to access the private project (403 Forbidden) but is a workspace admin (can join any project)
|
||||
if (errorStatusCode === 409 || (errorStatusCode === 403 && isWorkspaceAdmin))
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center bg-custom-background-100">
|
||||
<EmptyStateDetailed
|
||||
title={t("project_empty_state.no_access.title")}
|
||||
description={t("project_empty_state.no_access.join_description")}
|
||||
assetKey="no-access"
|
||||
assetClassName="size-40"
|
||||
actions={[
|
||||
{
|
||||
label: isJoinButtonDisabled
|
||||
? t("project_empty_state.no_access.cta_loading")
|
||||
: t("project_empty_state.no_access.cta_primary"),
|
||||
onClick: handleJoinProject,
|
||||
disabled: isJoinButtonDisabled,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Show no access screen if:
|
||||
// - User lacks permission to access the private project (403 Forbidden)
|
||||
if (errorStatusCode === 403) {
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center bg-custom-background-100">
|
||||
<EmptyStateDetailed
|
||||
title={t("project_empty_state.no_access.title")}
|
||||
description={t("project_empty_state.no_access.restricted_description")}
|
||||
assetKey="no-access"
|
||||
assetClassName="size-40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show empty state screen if:
|
||||
// - Project not found (404 Not Found)
|
||||
// - Any other error status code
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center bg-custom-background-100">
|
||||
<EmptyStateDetailed
|
||||
title={t("project_empty_state.invalid_project.title")}
|
||||
description={t("project_empty_state.invalid_project.description")}
|
||||
assetKey="project"
|
||||
assetClassName="size-40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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,15 +4,14 @@ import useSWR from "swr";
|
||||
// plane imports
|
||||
import { PRODUCT_TOUR_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { ContentWrapper } from "@plane/ui";
|
||||
// components
|
||||
import { TourRoot } from "@/components/onboarding/tour";
|
||||
// helpers
|
||||
import { captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { useUserProfile, useUser } from "@/hooks/store/user";
|
||||
// plane web components
|
||||
// plane web imports
|
||||
import { HomePeekOverviewsRoot } from "@/plane-web/components/home";
|
||||
import { TourRoot } from "@/plane-web/components/onboarding/tour/root";
|
||||
// local imports
|
||||
import { DashboardWidgets } from "./home-dashboard-widgets";
|
||||
import { UserGreetingsView } from "./user-greetings";
|
||||
@@ -53,7 +52,7 @@ export const WorkspaceHomeView = observer(function WorkspaceHomeView() {
|
||||
return (
|
||||
<>
|
||||
{currentUserProfile && !currentUserProfile.is_tour_completed && (
|
||||
<div className="fixed left-0 top-0 z-20 grid h-full w-full place-items-center bg-custom-backdrop bg-opacity-50 transition-opacity">
|
||||
<div className="fixed left-0 top-0 z-20 grid h-full w-full place-items-center bg-custom-backdrop bg-opacity-50 transition-opacity overflow-y-auto">
|
||||
<TourRoot onComplete={handleTourCompleted} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -6,7 +6,6 @@ import useSWR from "swr";
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
|
||||
// hooks
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
|
||||
@@ -26,7 +25,7 @@ export const ArchivedIssueLayoutRoot = observer(function ArchivedIssueLayoutRoot
|
||||
// derived values
|
||||
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `ARCHIVED_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
@@ -36,15 +35,7 @@ export const ArchivedIssueLayoutRoot = observer(function ArchivedIssueLayoutRoot
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
if (isLoading && !workItemFilters)
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId || !workItemFilters) return <></>;
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.ARCHIVED}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
|
||||
@@ -7,7 +7,6 @@ import useSWR from "swr";
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { TransferIssues } from "@/components/cycles/transfer-issues";
|
||||
import { TransferIssuesModal } from "@/components/cycles/transfer-issues-modal";
|
||||
// hooks
|
||||
@@ -59,7 +58,7 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
|
||||
const workItemFilters = cycleId ? issuesFilter?.getIssueFilters(cycleId) : undefined;
|
||||
const activeLayout = workItemFilters?.displayFilters?.layout;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
useSWR(
|
||||
workspaceSlug && projectId && cycleId ? `CYCLE_ISSUES_${workspaceSlug}_${projectId}_${cycleId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId && cycleId) {
|
||||
@@ -78,15 +77,7 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
|
||||
: 0;
|
||||
const canTransferIssues = isProgressSnapshotEmpty && transferableIssuesCount > 0;
|
||||
|
||||
if (!workspaceSlug || !projectId || !cycleId) return <></>;
|
||||
|
||||
if (isLoading && !workItemFilters)
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId || !cycleId || !workItemFilters) return <></>;
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.CYCLE}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
|
||||
@@ -6,8 +6,6 @@ import useSWR from "swr";
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
import { Row, ERowVariant } from "@plane/ui";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
// hooks
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
|
||||
@@ -50,7 +48,7 @@ export const ModuleLayoutRoot = observer(function ModuleLayoutRoot() {
|
||||
const workItemFilters = moduleId ? issuesFilter?.getIssueFilters(moduleId) : undefined;
|
||||
const activeLayout = workItemFilters?.displayFilters?.layout || undefined;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
useSWR(
|
||||
workspaceSlug && projectId && moduleId
|
||||
? `MODULE_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}_${moduleId.toString()}`
|
||||
: null,
|
||||
@@ -62,15 +60,7 @@ export const ModuleLayoutRoot = observer(function ModuleLayoutRoot() {
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId || !moduleId) return <></>;
|
||||
|
||||
if (isLoading && !workItemFilters)
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId || !moduleId || !workItemFilters) return <></>;
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.MODULE}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
@@ -7,7 +6,6 @@ import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@p
|
||||
import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
|
||||
// hooks
|
||||
@@ -49,7 +47,7 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() {
|
||||
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
|
||||
const activeLayout = workItemFilters?.displayFilters?.layout;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
@@ -59,15 +57,7 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() {
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
if (isLoading && !workItemFilters)
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId || !workItemFilters) return <></>;
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.PROJECT}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
|
||||
@@ -5,8 +5,6 @@ import useSWR from "swr";
|
||||
// plane constants
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
// hooks
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
|
||||
@@ -60,7 +58,7 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
useSWR(
|
||||
workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}_${viewId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId && viewId) {
|
||||
@@ -78,16 +76,7 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
|
||||
[issuesFilter, workspaceSlug, viewId]
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId || !viewId) return <></>;
|
||||
|
||||
if (isLoading && !workItemFilters) {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspaceSlug || !projectId || !viewId || !workItemFilters) return <></>;
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.PROJECT_VIEW}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
|
||||
@@ -6,7 +6,6 @@ import { SPREADSHEET_SELECT_GROUP, SPREADSHEET_PROPERTY_LIST } from "@plane/cons
|
||||
import type { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { EIssueLayoutTypes } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { MultipleSelectGroup } from "@/components/core/multiple-select";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
@@ -72,13 +71,7 @@ export const SpreadsheetView = observer(function SpreadsheetView(props: Props) {
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!issueIds || issueIds.length === 0)
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!issueIds || issueIds.length === 0) return <></>;
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-x-hidden whitespace-nowrap rounded-lg bg-custom-background-200 text-custom-text-200">
|
||||
<div ref={portalRef} className="spreadsheet-menu-portal" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user