Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 34 additions & 2 deletions apiserver/plane/app/serializers/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,26 @@ class IssueRelationSerializer(BaseSerializer):
)
name = serializers.CharField(source="related_issue.name", read_only=True)
relation_type = serializers.CharField(read_only=True)
state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True)
priority = serializers.CharField(source="related_issue.priority", read_only=True)
assignee_ids = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
Comment on lines +288 to +292
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix inconsistency in assignee_ids field implementation.

The assignee_ids field is marked as write_only=True but:

  1. It's not sourced from related_issue like other fields
  2. There are no create/update methods to handle the write operations

Consider one of these fixes:

-    assignee_ids = serializers.ListField(
-        child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
-        write_only=True,
-        required=False,
-    )
+    # Option 1: Make it read-only like other fields
+    assignee_ids = serializers.ListField(
+        child=serializers.UUIDField(),
+        source="related_issue.assignees.all",
+        read_only=True,
+    )
+
+    # Option 2: Add create/update methods to handle write operations
+    assignee_ids = serializers.ListField(
+        child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
+        write_only=True,
+        required=False,
+    )
+
+    def create(self, validated_data):
+        assignees = validated_data.pop("assignee_ids", None)
+        instance = super().create(validated_data)
+        if assignees is not None:
+            instance.related_issue.assignees.set(assignees)
+        return instance

Committable suggestion skipped: line range outside the PR's diff.


class Meta:
model = IssueRelation
fields = ["id", "project_id", "sequence_id", "relation_type", "name"]
fields = [
"id",
"project_id",
"sequence_id",
"relation_type",
"name",
"state_id",
"priority",
"assignee_ids",
]
read_only_fields = ["workspace", "project"]


Expand All @@ -298,10 +314,26 @@ class RelatedIssueSerializer(BaseSerializer):
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
name = serializers.CharField(source="issue.name", read_only=True)
relation_type = serializers.CharField(read_only=True)
state_id = serializers.UUIDField(source="issue.state.id", read_only=True)
priority = serializers.CharField(source="issue.priority", read_only=True)
assignee_ids = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)

class Meta:
model = IssueRelation
fields = ["id", "project_id", "sequence_id", "relation_type", "name"]
fields = [
"id",
"project_id",
"sequence_id",
"relation_type",
"name",
"state_id",
"priority",
"assignee_ids",
]
read_only_fields = ["workspace", "project"]


Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"use client";
import { FC, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { EIssueServiceType } from "@plane/constants";
import { TIssue, TIssueRelationIdMap, TIssueServiceType } from "@plane/types";
import { TIssue, TIssueServiceType } from "@plane/types";
import { Collapsible } from "@plane/ui";
// components
import { RelationIssueList } from "@/components/issues";
Expand All @@ -11,6 +12,7 @@ import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
// hooks
import { useIssueDetail } from "@/hooks/store";
// Plane-web
import { CreateUpdateEpicModal } from "@/plane-web/components/epics";
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
import { TIssueRelationTypes } from "@/plane-web/types";
// helper
Expand Down Expand Up @@ -62,6 +64,7 @@ export const RelationsCollapsibleContent: FC<Props> = observer((props) => {

// helper
const issueOperations = useRelationOperations();
const epicOperations = useRelationOperations(EIssueServiceType.EPICS);

// derived values
const relations = getRelationsByIssueId(issueId);
Expand Down Expand Up @@ -129,7 +132,6 @@ export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
relationKey={relation.relationKey}
issueIds={relation.issueIds}
disabled={disabled}
issueOperations={issueOperations}
handleIssueCrudState={handleIssueCrudState}
issueServiceType={issueServiceType}
/>
Expand All @@ -146,24 +148,44 @@ export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
toggleDeleteIssueModal(null);
}}
data={issueCrudState?.delete?.issue as TIssue}
onSubmit={async () =>
await issueOperations.remove(workspaceSlug, projectId, issueCrudState?.delete?.issue?.id as string)
}
onSubmit={async () => {
const deleteOperation = !!issueCrudState.delete.issue?.is_epic
? epicOperations.remove
: issueOperations.remove;
await deleteOperation(workspaceSlug, projectId, issueCrudState?.delete?.issue?.id as string);
}}
isEpic={!!issueCrudState.delete.issue?.is_epic}
/>
)}

{shouldRenderIssueUpdateModal && (
<CreateUpdateIssueModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
await issueOperations.update(workspaceSlug, projectId, _issue.id, _issue);
}}
/>
<>
{!!issueCrudState?.update?.issue?.is_epic ? (
<CreateUpdateEpicModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
await epicOperations.update(workspaceSlug, projectId, _issue.id, _issue);
}}
/>
) : (
<CreateUpdateIssueModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
await issueOperations.update(workspaceSlug, projectId, _issue.id, _issue);
}}
/>
)}
</>
)}
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const useRelationOperations = (
const { updateIssue, removeIssue } = useIssueDetail(issueServiceType);
const { captureIssueEvent } = useEventTracker();
const pathname = usePathname();
// derived values
const entityName = issueServiceType === EIssueServiceType.ISSUES ? "Issue" : "Epic";

const issueOperations: TRelationIssueOperations = useMemo(
() => ({
Expand All @@ -32,7 +34,7 @@ export const useRelationOperations = (
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Issue link copied to clipboard.",
message: `${entityName} link copied to clipboard.`,
});
});
},
Expand All @@ -51,7 +53,7 @@ export const useRelationOperations = (
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Issue updated successfully",
message: `${entityName} updated successfully`,
});
} catch (error) {
captureIssueEvent({
Expand All @@ -66,7 +68,7 @@ export const useRelationOperations = (
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Issue update failed",
message: `${entityName} update failed`,
});
}
},
Expand Down
109 changes: 52 additions & 57 deletions web/core/components/issues/relations/issue-list-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
import { TIssueRelationTypes } from "@/plane-web/types";
//
import { TRelationIssueOperations } from "../issue-detail-widgets/relations/helper";
// local imports
import { useRelationOperations } from "../issue-detail-widgets/relations/helper";

type Props = {
workspaceSlug: string;
Expand All @@ -26,7 +26,6 @@ type Props = {
relationKey: TIssueRelationTypes;
relationIssueId: string;
disabled: boolean;
issueOperations: TRelationIssueOperations;
handleIssueCrudState: (key: "update" | "delete", issueId: string, issue?: TIssue | null) => void;
issueServiceType?: TIssueServiceType;
};
Expand All @@ -39,7 +38,6 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
relationKey,
relationIssueId,
disabled = false,
issueOperations,
handleIssueCrudState,
issueServiceType = EIssueServiceType.ISSUES,
} = props;
Expand All @@ -57,6 +55,7 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
// derived values
const issue = getIssueById(relationIssueId);
const { handleRedirection } = useIssuePeekOverviewRedirection(!!issue?.is_epic);
const issueOperations = useRelationOperations(!!issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
const projectDetail = (issue && issue.project_id && project.getProjectById(issue.project_id)) || undefined;
const currentIssueStateDetail =
(issue?.project_id && getProjectStates(issue?.project_id)?.find((state) => issue?.state_id == state.id)) ||
Expand Down Expand Up @@ -134,62 +133,58 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
<span className="w-full truncate text-sm text-custom-text-100">{issue.name}</span>
</Tooltip>
</div>
{!issue.is_epic && (
<>
<div
className="flex-shrink-0 text-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<RelationIssueProperty
workspaceSlug={workspaceSlug}
issueId={relationIssueId}
disabled={disabled}
issueOperations={issueOperations}
issueServiceType={issueServiceType}
/>
</div>
<div className="flex-shrink-0 text-sm">
<CustomMenu placement="bottom-end" ellipsis>
{!disabled && (
<CustomMenu.MenuItem onClick={handleEditIssue}>
<div className="flex items-center gap-2">
<Pencil className="h-3.5 w-3.5" strokeWidth={2} />
<span>Edit issue</span>
</div>
</CustomMenu.MenuItem>
)}
<div
className="flex-shrink-0 text-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<RelationIssueProperty
workspaceSlug={workspaceSlug}
issueId={relationIssueId}
disabled={disabled}
issueOperations={issueOperations}
issueServiceType={issueServiceType}
/>
</div>
<div className="flex-shrink-0 text-sm">
<CustomMenu placement="bottom-end" ellipsis>
{!disabled && (
<CustomMenu.MenuItem onClick={handleEditIssue}>
<div className="flex items-center gap-2">
<Pencil className="h-3.5 w-3.5" strokeWidth={2} />
<span>Edit</span>
</div>
</CustomMenu.MenuItem>
)}

<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2">
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>Copy issue link</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2">
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
<span>Copy link</span>
</div>
</CustomMenu.MenuItem>

{!disabled && (
<CustomMenu.MenuItem onClick={handleRemoveRelation}>
<div className="flex items-center gap-2">
<X className="h-3.5 w-3.5" strokeWidth={2} />
<span>Remove relation</span>
</div>
</CustomMenu.MenuItem>
)}
{!disabled && (
<CustomMenu.MenuItem onClick={handleRemoveRelation}>
<div className="flex items-center gap-2">
<X className="h-3.5 w-3.5" strokeWidth={2} />
<span>Remove relation</span>
</div>
</CustomMenu.MenuItem>
)}

{!disabled && (
<CustomMenu.MenuItem onClick={handleDeleteIssue}>
<div className="flex items-center gap-2">
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
<span>Delete issue</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
</div>
</>
)}
{!disabled && (
<CustomMenu.MenuItem onClick={handleDeleteIssue}>
<div className="flex items-center gap-2">
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
<span>Delete</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
</div>
</div>
)}
</ControlLink>
Expand Down
5 changes: 0 additions & 5 deletions web/core/components/issues/relations/issue-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,13 @@ import { TIssue, TIssueServiceType } from "@plane/types";
import { RelationIssueListItem } from "@/components/issues/relations";
// Plane-web
import { TIssueRelationTypes } from "@/plane-web/types";
//
import { TRelationIssueOperations } from "../issue-detail-widgets/relations/helper";

type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
issueIds: string[];
relationKey: TIssueRelationTypes;
issueOperations: TRelationIssueOperations;
handleIssueCrudState: (key: "update" | "delete", issueId: string, issue?: TIssue | null) => void;
disabled?: boolean;
issueServiceType?: TIssueServiceType;
Expand All @@ -31,7 +28,6 @@ export const RelationIssueList: FC<Props> = observer((props) => {
issueIds,
relationKey,
disabled = false,
issueOperations,
handleIssueCrudState,
issueServiceType = EIssueServiceType.ISSUES,
} = props;
Expand All @@ -50,7 +46,6 @@ export const RelationIssueList: FC<Props> = observer((props) => {
relationIssueId={relationIssueId}
disabled={disabled}
handleIssueCrudState={handleIssueCrudState}
issueOperations={issueOperations}
issueServiceType={issueServiceType}
/>
))}
Expand Down
Loading