Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0ec1b91
feat: meta endpoint for issue
pablohashescobar Feb 4, 2025
659656d
chore: add detail endpoint
pablohashescobar Feb 4, 2025
629decd
chore: getIssueMetaFromURL and retrieveWithIdentifier endpoint added
anmolsinghbhatia Feb 4, 2025
a97ca4b
chore: issue store updated
anmolsinghbhatia Feb 4, 2025
0a9bf13
chore: move issue detail to new route and add redirection for old route
anmolsinghbhatia Feb 4, 2025
e18962b
fix: issue details permission
anmolsinghbhatia Feb 4, 2025
4f53263
fix: work item detail header
anmolsinghbhatia Feb 4, 2025
396bc0e
chore: generateWorkItemLink helper function added
anmolsinghbhatia Feb 5, 2025
d68c199
chore: copyTextToClipboard helper function updated
anmolsinghbhatia Feb 5, 2025
3939271
chore: workItemLink updated
anmolsinghbhatia Feb 5, 2025
39fbd3d
chore: workItemLink updated
anmolsinghbhatia Feb 5, 2025
1a523ef
chore: workItemLink updated
anmolsinghbhatia Feb 5, 2025
1ca31e0
fix: issues navigation tab active status
anmolsinghbhatia Feb 5, 2025
088741b
fix: invalid workitem error state
anmolsinghbhatia Feb 6, 2025
974ad13
chore: peek view parent issue redirection improvement
anmolsinghbhatia Feb 6, 2025
7d67c0f
fix: issue detail endpoint to not return epics and intake issue
pablohashescobar Feb 6, 2025
89e8f60
Merge branch 'feat-url-pattern' of github.com:makeplane/plane into fe…
pablohashescobar Feb 6, 2025
b69256b
fix: workitem empty state redirection and header
anmolsinghbhatia Feb 11, 2025
0d538dc
fix: workitem empty state redirection and header
anmolsinghbhatia Feb 11, 2025
ac8e8f8
Merge branch 'preview' of github.com:makeplane/plane into feat-url-pa…
anmolsinghbhatia Feb 12, 2025
332c4c6
chore: code refactor
anmolsinghbhatia Feb 12, 2025
1bbc198
Merge branch 'preview' of github.com:makeplane/plane into feat-url-pa…
anmolsinghbhatia Feb 14, 2025
5ea512f
chore: project auth wrapper improvement
anmolsinghbhatia Feb 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apiserver/plane/app/urls/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
IssueBulkUpdateDateEndpoint,
IssueVersionEndpoint,
IssueDescriptionVersionEndpoint,
IssueMetaEndpoint,
IssueDetailIdentifierEndpoint,
)

urlpatterns = [
Expand Down Expand Up @@ -278,4 +280,14 @@
IssueDescriptionVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/meta/",
IssueMetaEndpoint.as_view(),
name="issue-meta",
),
path(
"workspaces/<str:slug>/work-items/<str:project_identifier>-<str:issue_identifier>/",
IssueDetailIdentifierEndpoint.as_view(),
name="issue-detail-identifier",
),
]
2 changes: 2 additions & 0 deletions apiserver/plane/app/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@
IssuePaginatedViewSet,
IssueDetailEndpoint,
IssueBulkUpdateDateEndpoint,
IssueMetaEndpoint,
IssueDetailIdentifierEndpoint,
)

from .issue.activity import IssueActivityEndpoint
Expand Down
175 changes: 175 additions & 0 deletions apiserver/plane/app/views/issue/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1096,3 +1096,178 @@ def post(self, request, slug, project_id):
return Response(
{"message": "Issues updated successfully"}, status=status.HTTP_200_OK
)


class IssueMetaEndpoint(BaseAPIView):

@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
def get(self, request, slug, project_id, issue_id):
issue = Issue.issue_objects.only("sequence_id", "project__identifier").get(
id=issue_id, project_id=project_id, workspace__slug=slug
)
return Response(
{
"sequence_id": issue.sequence_id,
"project_identifier": issue.project.identifier,
},
status=status.HTTP_200_OK,
)


class IssueDetailIdentifierEndpoint(BaseAPIView):

def get(self, request, slug, project_identifier, issue_identifier):

# Fetch the project
project = Project.objects.get(
identifier__iexact=project_identifier,
workspace__slug=slug,
)

# Check if the user is a member of the project
if not ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project.id,
member=request.user,
is_active=True,
).exists():
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_403_FORBIDDEN,
)

# Fetch the issue
issue = (
Issue.issue_objects.filter(project_id=project.id)
.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
:1
]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(sequence_id=issue_identifier)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("issue", "actor"),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project.id,
issue__sequence_id=issue_identifier,
subscriber=request.user,
)
)
)
).first()

# Check if the issue exists
if not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)

"""
if the role is guest and guest_view_all_features is false and owned by is not
the requesting user then dont show the issue
"""

if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project.id,
member=request.user,
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
and not issue.created_by == request.user
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
)
Comment on lines +1258 to +1261
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use HTTP 403 for permission-related errors.

For consistency, use HTTP 403 (Forbidden) instead of HTTP 400 (Bad Request) when denying access to guest users. This aligns with HTTP standards where 403 indicates the server understood the request but refuses to authorize it.

-                status=status.HTTP_400_BAD_REQUEST,
+                status=status.HTTP_403_FORBIDDEN,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_403_FORBIDDEN,
)


recent_visited_task.delay(
slug=slug,
entity_name="issue",
entity_identifier=str(issue.id),
user_id=str(request.user.id),
project_id=str(project.id),
)

# Serialize the issue
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
2 changes: 1 addition & 1 deletion packages/types/src/issues/activity/base.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export type TIssueActivityProjectDetail = {

export type TIssueActivityIssueDetail = {
id: string;
sequence_id: boolean;
sequence_id: number;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify impact of type change across the codebase.

This is a breaking change that requires updates to any code using the TIssueActivityIssueDetail type.

Let's verify the impact:


🏁 Script executed:

#!/bin/bash
# Description: Find usages of TIssueActivityIssueDetail and sequence_id to identify potential breaking changes

# Find files importing or using TIssueActivityIssueDetail
echo "Files potentially affected by the type change:"
rg "TIssueActivityIssueDetail"

# Find direct uses of sequence_id property
echo -e "\nDirect uses of sequence_id:"
rg "sequence_id" --type ts --type tsx

Length of output: 719


🏁 Script executed:

#!/bin/bash
# Re-run the search for direct uses of the sequence_id property across TypeScript, TSX, and declaration files using glob patterns instead of file type flags

echo "Direct uses of sequence_id across *.d.ts, *.ts, and *.tsx files:"
rg sequence_id -g "*.d.ts" -g "*.ts" -g "*.tsx"

Length of output: 13269

Impact of Changing sequence_id Type in TIssueActivityIssueDetail

Our investigation shows that the updated type in
  packages/types/src/issues/activity/base.d.ts
declares sequence_id as a number. However, multiple parts of the codebase reference sequence_id—and in several declarations (e.g., in packages/types/src/inbox.d.ts and in web/core/services/issue/issue.service.ts) it is defined or used as a string. This discrepancy indicates that changing the type here is indeed a breaking change that will affect any consumers of TIssueActivityIssueDetail relying on a string type.

  • Affected areas:
    • Files where TIssueActivityIssueDetail is imported (e.g., issue activity and issue comment types).
    • Modules that explicitly type sequence_id as a string.

Please review and update all consumers of TIssueActivityIssueDetail to accommodate the new type (or adjust the type change if unintended).

sort_order: boolean;
name: string;
description_html: string;
Expand Down
94 changes: 94 additions & 0 deletions web/app/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"use client";

import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Briefcase } from "lucide-react";
// ui
import { Breadcrumbs, LayersIcon, Header, Logo } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { IssueDetailQuickActions } from "@/components/issues";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";

export const ProjectIssueDetailsHeader = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
// store hooks
const { getProjectById, loader } = useProject();
const {
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail();
// derived values
const issueId = getIssueIdByIdentifier(workItem?.toString());
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
const projectId = issueDetails ? issueDetails?.project_id : undefined;
const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined;

if (!workspaceSlug || !projectId || !issueId) return null;

return (
<Header>
<Header.LeftItem>
<div>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={projectDetails?.name ?? "Project"}
icon={
projectDetails ? (
projectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Logo logo={projectDetails?.logo_props} size={16} />
</span>
)
) : (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Briefcase className="h-4 w-4" />
</span>
)
}
/>
}
/>

<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/issues`}
label="Issues"
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>

<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={
projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""
}
/>
}
/>
</Breadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>
{projectId && issueId && (
<IssueDetailQuickActions
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
issueId={issueId?.toString()}
/>
)}
</Header.RightItem>
</Header>
);
});
Loading