Skip to content
Open
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
16 changes: 16 additions & 0 deletions apps/api/plane/app/views/issue/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
issue_queryset_grouper,
)
from plane.utils.host import base_host
from plane.utils.sub_issue_state_propagation import propagate_state_to_sub_issues
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
Expand Down Expand Up @@ -630,6 +631,12 @@ def partial_update(self, request, slug, project_id, pk=None):
queryset = self.apply_annotations(queryset)

skip_activity = request.data.pop("skip_activity", False)
propagate_state_to_sub_issues_flag = request.data.pop("propagate_state_to_sub_issues", False)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not isinstance(propagate_state_to_sub_issues_flag, bool):
return Response(
{"propagate_state_to_sub_issues": ["This field must be a boolean."]},
status=status.HTTP_400_BAD_REQUEST,
)
is_description_update = request.data.get("description_html") is not None

issue = (
Expand Down Expand Up @@ -710,6 +717,15 @@ def partial_update(self, request, slug, project_id, pk=None):
issue_id=str(serializer.data.get("id", None)),
user_id=request.user.id,
)
if propagate_state_to_sub_issues_flag and "state_id" in request.data:
issue = Issue.issue_objects.select_related("state").get(pk=issue.id)
propagate_state_to_sub_issues(
parent=issue,
new_state=issue.state,
actor=request.user,
workspace_slug=slug,
origin=base_host(request=request, is_app=True),
)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

Expand Down
168 changes: 168 additions & 0 deletions apps/api/plane/tests/unit/utils/test_sub_issue_state_propagation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from unittest.mock import patch

import pytest

from plane.db.models import Issue, Project, ProjectMember, State, Workspace
from plane.utils.sub_issue_state_propagation import (
propagate_state_to_sub_issues,
resolve_target_state,
user_can_edit_issue,
)


@pytest.fixture
def project(workspace, create_user):
return Project.objects.create(
name="Test Project",
identifier="TP",
workspace=workspace,
created_by=create_user,
)


@pytest.fixture
def todo_state(project):
return State.objects.create(
name="Todo",
project=project,
group="unstarted",
color="#60646C",
)


@pytest.fixture
def done_state(project):
return State.objects.create(
name="Done",
project=project,
group="completed",
color="#46A758",
)


@pytest.fixture
def parent_issue(workspace, project, todo_state, create_user):
return Issue.objects.create(
name="Parent Issue",
workspace=workspace,
project=project,
state=todo_state,
created_by=create_user,
)


@pytest.fixture
def sub_issue(workspace, project, todo_state, create_user, parent_issue):
return Issue.objects.create(
name="Sub Issue",
workspace=workspace,
project=project,
state=todo_state,
parent=parent_issue,
created_by=create_user,
)


@pytest.mark.unit
class TestResolveTargetState:
@pytest.mark.django_db
def test_returns_same_state_for_same_project(self, project, todo_state):
assert resolve_target_state(todo_state, project.id) == todo_state

@pytest.mark.django_db
def test_resolves_state_by_group_and_name(self, workspace, create_user, todo_state):
other_project = Project.objects.create(
name="Other Project",
identifier="OP",
workspace=workspace,
created_by=create_user,
)
other_todo = State.objects.create(
name="Todo",
project=other_project,
group="unstarted",
color="#60646C",
)
assert resolve_target_state(todo_state, other_project.id) == other_todo

@pytest.mark.django_db
def test_falls_back_to_group_state(self, workspace, create_user, todo_state):
other_project = Project.objects.create(
name="Other Project",
identifier="OP",
workspace=workspace,
created_by=create_user,
)
fallback_state = State.objects.create(
name="Backlog",
project=other_project,
group="unstarted",
color="#60646C",
sequence=1000,
)
assert resolve_target_state(todo_state, other_project.id) == fallback_state


@pytest.mark.unit
class TestPropagateStateToSubIssues:
@pytest.mark.django_db
@patch("plane.utils.sub_issue_state_propagation.issue_activity.delay")
def test_propagates_state_to_direct_sub_issues(
self, mock_issue_activity, workspace, create_user, parent_issue, sub_issue, done_state
):
ProjectMember.objects.create(
workspace=workspace,
project=parent_issue.project,
member=create_user,
role=20,
)

updated_ids = propagate_state_to_sub_issues(
parent=parent_issue,
new_state=done_state,
actor=create_user,
workspace_slug=workspace.slug,
origin="http://localhost",
)

sub_issue.refresh_from_db()
assert updated_ids == [str(sub_issue.id)]
assert sub_issue.state_id == done_state.id
assert sub_issue.completed_at is not None
mock_issue_activity.assert_called_once()

@pytest.mark.django_db
@patch("plane.utils.sub_issue_state_propagation.issue_activity.delay")
def test_skips_sub_issues_user_cannot_edit(
self, mock_issue_activity, workspace, create_user, parent_issue, sub_issue, done_state, user_data
):
from plane.db.models import User

other_user = User.objects.create(
email="other@plane.so",
first_name="Other",
last_name="User",
)
sub_issue.created_by = other_user
sub_issue.save()

updated_ids = propagate_state_to_sub_issues(
parent=parent_issue,
new_state=done_state,
actor=create_user,
workspace_slug=workspace.slug,
origin="http://localhost",
)

sub_issue.refresh_from_db()
assert updated_ids == []
assert sub_issue.state_id != done_state.id
mock_issue_activity.assert_not_called()

@pytest.mark.django_db
def test_user_can_edit_issue_for_creator(self, workspace, create_user, sub_issue):
assert user_can_edit_issue(create_user, workspace.slug, sub_issue) is True
100 changes: 100 additions & 0 deletions apps/api/plane/utils/sub_issue_state_propagation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

import json

from django.utils import timezone

from plane.app.permissions.base import ROLE
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import Issue, ProjectMember, State, WorkspaceMember


def user_can_edit_issue(user, workspace_slug, issue):
"""Check if the user can edit an issue, mirroring partial_update permissions."""
if issue.created_by_id == user.id:
return True

allowed_roles = [ROLE.ADMIN.value, ROLE.MEMBER.value]
if ProjectMember.objects.filter(
member=user,
workspace__slug=workspace_slug,
project_id=issue.project_id,
role__in=allowed_roles,
is_active=True,
).exists():
return True

return (
ProjectMember.objects.filter(
member=user,
workspace__slug=workspace_slug,
project_id=issue.project_id,
is_active=True,
).exists()
and WorkspaceMember.objects.filter(
member=user,
workspace__slug=workspace_slug,
role=ROLE.ADMIN.value,
is_active=True,
).exists()
)


def resolve_target_state(new_state, target_project_id):
"""Resolve the equivalent state for a sub-issue's project."""
if str(new_state.project_id) == str(target_project_id):
return new_state

# Prefer a state with the same group and name in the target project
matching_state = State.objects.filter(
project_id=target_project_id,
group=new_state.group,
name=new_state.name,
).first()
if matching_state:
return matching_state

# Fall back to any state in the same group
return State.objects.filter(project_id=target_project_id, group=new_state.group).order_by("sequence").first()


def propagate_state_to_sub_issues(parent, new_state, actor, workspace_slug, origin):
"""
Propagate a state change from a parent issue to its direct sub-issues.
Returns the list of updated sub-issue IDs.
"""
if not new_state:
return []

sub_issues = Issue.issue_objects.filter(parent_id=parent.id, workspace=parent.workspace).select_related("state")
updated_sub_issue_ids = []

for sub_issue in sub_issues:
if not user_can_edit_issue(actor, workspace_slug, sub_issue):
continue

target_state = resolve_target_state(new_state, sub_issue.project_id)
if not target_state or sub_issue.state_id == target_state.id:
continue

current_instance = json.dumps({"state_id": str(sub_issue.state_id)})
sub_issue.state = target_state
sub_issue.updated_by = actor
sub_issue.save()

issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"state_id": str(target_state.id)}),
actor_id=str(actor.id),
issue_id=str(sub_issue.id),
project_id=str(sub_issue.project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=origin,
)
updated_sub_issue_ids.append(str(sub_issue.id))

return updated_sub_issue_ids
2 changes: 2 additions & 0 deletions apps/web/core/components/issues/issue-detail/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { EIssuesStoreType } from "@plane/types";
import emptyIssue from "@/app/assets/empty-state/issue.svg?url";
// components
import { EmptyState } from "@/components/common/empty-state";
import { PropagateStateModalRoot } from "@/components/issues/propagate-state-modal";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
Expand Down Expand Up @@ -266,6 +267,7 @@ export const IssueDetailRoot = observer(function IssueDetailRoot(props: TIssueDe

{/* peek overview */}
<IssuePeekOverview />
<PropagateStateModalRoot />
</>
);
});
17 changes: 16 additions & 1 deletion apps/web/core/components/issues/issue-detail/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
// helpers
import { updateIssueStateWithPropagation } from "@/helpers/issue-state-update";
// components
import { IssueParentSelectRoot } from "@/components/issues/parent-select-root";
import { SidebarPropertyListItem } from "@/components/common/layout/sidebar/property-list-item";
Expand All @@ -59,6 +61,7 @@ export const IssueDetailsSidebar = observer(function IssueDetailsSidebar(props:
const { areEstimateEnabledByProjectId } = useProjectEstimates();
const {
issue: { getIssueById },
subIssues: { fetchSubIssues },
} = useIssueDetail();
const { getUserDetails } = useMember();
const { getStateById } = useProjectState();
Expand All @@ -77,6 +80,18 @@ export const IssueDetailsSidebar = observer(function IssueDetailsSidebar(props:
const maxDate = issue.target_date ? getDate(issue.target_date) : null;
maxDate?.setDate(maxDate.getDate());

const handleStateChange = async (stateId: string) => {
await updateIssueStateWithPropagation({
currentStateId: issue.state_id,
newStateId: stateId,
subIssuesCount: issue.sub_issues_count ?? 0,
onUpdate: async (data) => issueOperations.update(workspaceSlug, projectId, issueId, data),
afterPropagate: async () => {
await fetchSubIssues(workspaceSlug, projectId, issueId);
},
});
};

return (
<>
<div className="flex h-full w-full flex-col items-center divide-y-2 divide-subtle-1 overflow-hidden">
Expand All @@ -86,7 +101,7 @@ export const IssueDetailsSidebar = observer(function IssueDetailsSidebar(props:
<SidebarPropertyListItem icon={StatePropertyIcon} label={t("common.state")}>
<StateDropdown
value={issue?.state_id}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })}
onChange={handleStateChange}
projectId={projectId?.toString() ?? ""}
disabled={!isEditable}
buttonVariant="transparent-with-text"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { observer } from "mobx-react";
import { EIssueLayoutTypes } from "@plane/types";
// components
import { LayoutErrorBoundary } from "@/components/common/layout-error-boundary";
import { PropagateStateModalRoot } from "@/components/issues/propagate-state-modal";
import { CalendarLayoutLoader } from "@/components/ui/loader/layouts/calendar-layout-loader";
import { GanttLayoutLoader } from "@/components/ui/loader/layouts/gantt-layout-loader";
import { KanbanLayoutLoader } from "@/components/ui/loader/layouts/kanban-layout-loader";
Expand Down Expand Up @@ -59,5 +60,10 @@ export const IssueLayoutHOC = observer(function IssueLayoutHOC(props: Props) {
return <IssueLayoutEmptyState storeType={storeType} />;
}

return <LayoutErrorBoundary key={layout}>{props.children}</LayoutErrorBoundary>;
return (
<LayoutErrorBoundary key={layout}>
{props.children}
<PropagateStateModalRoot />
</LayoutErrorBoundary>
);
});
Loading