From 6f26b70277030631bd454ea66c9350b0fc2d27a5 Mon Sep 17 00:00:00 2001 From: iyamokuma Date: Fri, 14 Aug 2026 00:44:26 -0700 Subject: [PATCH 1/3] feat: add optional state propagation from parent to sub-work items When changing a parent work item's state, users can opt in to cascade the same state to direct sub-work items via a confirmation modal. Backend propagation resolves equivalent states across projects and logs activity. Co-authored-by: Cursor --- apps/api/plane/app/views/issue/base.py | 11 ++ .../utils/test_sub_issue_state_propagation.py | 168 ++++++++++++++++++ .../utils/sub_issue_state_propagation.py | 100 +++++++++++ .../components/issues/issue-detail/root.tsx | 2 + .../issues/issue-detail/sidebar.tsx | 15 +- .../issues/issue-layouts/issue-layout-HOC.tsx | 8 +- .../properties/all-properties.tsx | 11 +- .../issues/peek-overview/properties.tsx | 13 +- .../issues/propagate-state-modal.tsx | 56 ++++++ apps/web/core/helpers/issue-state-update.ts | 39 ++++ apps/web/core/hooks/use-group-dragndrop.ts | 25 +++ .../core/store/issue/propagate-state.store.ts | 52 ++++++ packages/i18n/src/locales/cs/work-item.json | 7 + packages/i18n/src/locales/de/work-item.json | 7 + packages/i18n/src/locales/en/work-item.json | 7 + packages/i18n/src/locales/es/work-item.json | 7 + packages/i18n/src/locales/fr/work-item.json | 7 + packages/i18n/src/locales/id/work-item.json | 7 + packages/i18n/src/locales/it/work-item.json | 7 + packages/i18n/src/locales/ja/work-item.json | 7 + packages/i18n/src/locales/ko/work-item.json | 7 + packages/i18n/src/locales/pl/work-item.json | 7 + .../i18n/src/locales/pt-BR/work-item.json | 7 + packages/i18n/src/locales/ro/work-item.json | 7 + packages/i18n/src/locales/ru/work-item.json | 7 + packages/i18n/src/locales/sk/work-item.json | 7 + .../i18n/src/locales/tr-TR/work-item.json | 7 + packages/i18n/src/locales/ua/work-item.json | 7 + .../i18n/src/locales/vi-VN/work-item.json | 7 + .../i18n/src/locales/zh-CN/work-item.json | 7 + .../i18n/src/locales/zh-TW/work-item.json | 7 + packages/types/src/issues/issue.ts | 4 + 32 files changed, 632 insertions(+), 5 deletions(-) create mode 100644 apps/api/plane/tests/unit/utils/test_sub_issue_state_propagation.py create mode 100644 apps/api/plane/utils/sub_issue_state_propagation.py create mode 100644 apps/web/core/components/issues/propagate-state-modal.tsx create mode 100644 apps/web/core/helpers/issue-state-update.ts create mode 100644 apps/web/core/store/issue/propagate-state.store.ts diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 3b8b05a6a2f..ef7cddb0f24 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -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 @@ -630,6 +631,7 @@ 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) is_description_update = request.data.get("description_html") is not None issue = ( @@ -710,6 +712,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) diff --git a/apps/api/plane/tests/unit/utils/test_sub_issue_state_propagation.py b/apps/api/plane/tests/unit/utils/test_sub_issue_state_propagation.py new file mode 100644 index 00000000000..bc8d0b2f171 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_sub_issue_state_propagation.py @@ -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 diff --git a/apps/api/plane/utils/sub_issue_state_propagation.py b/apps/api/plane/utils/sub_issue_state_propagation.py new file mode 100644 index 00000000000..f66a0a2c025 --- /dev/null +++ b/apps/api/plane/utils/sub_issue_state_propagation.py @@ -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 diff --git a/apps/web/core/components/issues/issue-detail/root.tsx b/apps/web/core/components/issues/issue-detail/root.tsx index 72484c696ba..1c47053bb98 100644 --- a/apps/web/core/components/issues/issue-detail/root.tsx +++ b/apps/web/core/components/issues/issue-detail/root.tsx @@ -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"; @@ -266,6 +267,7 @@ export const IssueDetailRoot = observer(function IssueDetailRoot(props: TIssueDe {/* peek overview */} + ); }); diff --git a/apps/web/core/components/issues/issue-detail/sidebar.tsx b/apps/web/core/components/issues/issue-detail/sidebar.tsx index 8077c4ef947..b3da571ae91 100644 --- a/apps/web/core/components/issues/issue-detail/sidebar.tsx +++ b/apps/web/core/components/issues/issue-detail/sidebar.tsx @@ -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"; @@ -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(); @@ -77,6 +80,16 @@ 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 () => fetchSubIssues(workspaceSlug, projectId, issueId), + }); + }; + return ( <>
@@ -86,7 +99,7 @@ export const IssueDetailsSidebar = observer(function IssueDetailsSidebar(props: issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })} + onChange={handleStateChange} projectId={projectId?.toString() ?? ""} disabled={!isEditable} buttonVariant="transparent-with-text" diff --git a/apps/web/core/components/issues/issue-layouts/issue-layout-HOC.tsx b/apps/web/core/components/issues/issue-layouts/issue-layout-HOC.tsx index 2c6cd65f494..353bd51c8bb 100644 --- a/apps/web/core/components/issues/issue-layouts/issue-layout-HOC.tsx +++ b/apps/web/core/components/issues/issue-layouts/issue-layout-HOC.tsx @@ -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"; @@ -59,5 +60,10 @@ export const IssueLayoutHOC = observer(function IssueLayoutHOC(props: Props) { return ; } - return {props.children}; + return ( + + {props.children} + + + ); }); diff --git a/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx b/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx index cd2ffbffc57..b2ae70de844 100644 --- a/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx +++ b/apps/web/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -16,7 +16,6 @@ import { useTranslation } from "@plane/i18n"; import { LinkIcon, StartDatePropertyIcon, ViewsIcon, DueDatePropertyIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import type { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; -// ui import { cn, getDate, @@ -42,6 +41,8 @@ import { useProjectState } from "@/hooks/store/use-project-state"; import { useAppRouter } from "@/hooks/use-app-router"; import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// helpers +import { updateIssueStateWithPropagation } from "@/helpers/issue-state-update"; // local components import { IssuePropertyLabels } from "./labels"; import { WithDisplayPropertiesHOC } from "./with-display-properties-HOC"; @@ -106,7 +107,13 @@ export const IssueProperties = observer(function IssueProperties(props: IIssuePr ); const handleState = async (stateId: string) => { - if (updateIssue) await updateIssue(issue.project_id, issue.id, { state_id: stateId }); + if (!updateIssue) return; + await updateIssueStateWithPropagation({ + currentStateId: issue.state_id, + newStateId: stateId, + subIssuesCount: issue.sub_issues_count ?? 0, + onUpdate: async (data) => updateIssue(issue.project_id, issue.id, data), + }); }; const handlePriority = async (value: TIssuePriorities) => { diff --git a/apps/web/core/components/issues/peek-overview/properties.tsx b/apps/web/core/components/issues/peek-overview/properties.tsx index 8f35cc1645a..60e1da50a5c 100644 --- a/apps/web/core/components/issues/peek-overview/properties.tsx +++ b/apps/web/core/components/issues/peek-overview/properties.tsx @@ -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"; // plane web components import { IssueParentSelectRoot } from "@/components/issues/parent-select-root"; import type { TIssueOperations } from "../issue-detail"; @@ -74,6 +76,15 @@ export const PeekOverviewProperties = observer(function PeekOverviewProperties(p const maxDate = getDate(issue.target_date); 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), + }); + }; + return (
{t("common.properties")}
@@ -81,7 +92,7 @@ export const PeekOverviewProperties = observer(function PeekOverviewProperties(p issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })} + onChange={handleStateChange} projectId={projectId} disabled={disabled} buttonVariant="transparent-with-text" diff --git a/apps/web/core/components/issues/propagate-state-modal.tsx b/apps/web/core/components/issues/propagate-state-modal.tsx new file mode 100644 index 00000000000..6fb1d28467c --- /dev/null +++ b/apps/web/core/components/issues/propagate-state-modal.tsx @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; +import { AlertModalCore, ToggleSwitch } from "@plane/ui"; +import { propagateStateStore } from "@/store/issue/propagate-state.store"; + +export const PropagateStateModalRoot = observer(function PropagateStateModalRoot() { + const { t } = useTranslation(); + const { promptData, propagateToSubIssues, isSubmitting } = propagateStateStore; + + if (!promptData) return null; + + const handleClose = () => { + propagateStateStore.cancel(); + }; + + const handleSubmit = () => { + propagateStateStore.confirm(); + }; + + return ( + +

+ {t("sub_work_item.propagate_state.modal.description", { count: promptData.subIssuesCount })} +

+
+ {t("sub_work_item.propagate_state.modal.toggle")} + propagateStateStore.setPropagateToSubIssues(value)} + disabled={isSubmitting} + /> +
+
+ } + handleClose={handleClose} + handleSubmit={handleSubmit} + isSubmitting={isSubmitting} + primaryButtonText={{ + loading: t("common.updating"), + default: t("common.update"), + }} + secondaryButtonText={t("common.cancel")} + /> + ); +}); diff --git a/apps/web/core/helpers/issue-state-update.ts b/apps/web/core/helpers/issue-state-update.ts new file mode 100644 index 00000000000..867bbd99a20 --- /dev/null +++ b/apps/web/core/helpers/issue-state-update.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { TIssuePatchPayload } from "@plane/types"; +import { propagateStateStore } from "@/store/issue/propagate-state.store"; + +type TUpdateIssueStateWithPropagationParams = { + currentStateId: string | null | undefined; + newStateId: string; + subIssuesCount: number; + onUpdate: (data: TIssuePatchPayload) => Promise; + afterPropagate?: () => Promise; +}; + +export async function updateIssueStateWithPropagation(params: TUpdateIssueStateWithPropagationParams): Promise { + const { currentStateId, newStateId, subIssuesCount, onUpdate, afterPropagate } = params; + + if (currentStateId === newStateId) return; + + if (subIssuesCount > 0) { + const shouldPropagate = await propagateStateStore.prompt(subIssuesCount); + if (shouldPropagate === null) return; + + await onUpdate({ + state_id: newStateId, + propagate_state_to_sub_issues: shouldPropagate, + }); + + if (shouldPropagate && afterPropagate) { + await afterPropagate(); + } + return; + } + + await onUpdate({ state_id: newStateId }); +} diff --git a/apps/web/core/hooks/use-group-dragndrop.ts b/apps/web/core/hooks/use-group-dragndrop.ts index b21cdcbe10d..802115dde26 100644 --- a/apps/web/core/hooks/use-group-dragndrop.ts +++ b/apps/web/core/hooks/use-group-dragndrop.ts @@ -9,6 +9,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { EIssuesStoreType, TIssue, TIssueGroupByOptions, TIssueOrderByOptions } from "@plane/types"; import type { GroupDropLocation } from "@/components/issues/issue-layouts/utils"; import { handleGroupDragDrop } from "@/components/issues/issue-layouts/utils"; +import { updateIssueStateWithPropagation } from "@/helpers/issue-state-update"; import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/base-issues.store"; import { useIssueDetail } from "./store/use-issue-detail"; import { useIssues } from "./store/use-issues"; @@ -94,6 +95,30 @@ export const useGroupIssuesDragNDrop = ( delete data[moduleKey]; } + const issue = getIssueById(issueId); + const stateId = data.state_id; + + if (stateId && issue && issue.sub_issues_count > 0) { + const remainingData = { ...data }; + delete remainingData.state_id; + + try { + await updateIssueStateWithPropagation({ + currentStateId: issue.state_id, + newStateId: stateId, + subIssuesCount: issue.sub_issues_count, + onUpdate: async (updateData) => { + if (updateIssue) { + await updateIssue(projectId, issueId, { ...remainingData, ...updateData }); + } + }, + }); + } catch { + setToast(errorToastProps); + } + return; + } + updateIssue && updateIssue(projectId, issueId, data).catch(() => setToast(errorToastProps)); }; diff --git a/apps/web/core/store/issue/propagate-state.store.ts b/apps/web/core/store/issue/propagate-state.store.ts new file mode 100644 index 00000000000..0e03326b721 --- /dev/null +++ b/apps/web/core/store/issue/propagate-state.store.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { action, makeAutoObservable } from "mobx"; + +type TPropagateStatePrompt = { + subIssuesCount: number; + resolve: (propagate: boolean | null) => void; +}; + +class PropagateStateStore { + promptData: TPropagateStatePrompt | null = null; + propagateToSubIssues = false; + isSubmitting = false; + + constructor() { + makeAutoObservable(this); + } + + prompt(subIssuesCount: number): Promise { + return new Promise((resolve) => { + this.propagateToSubIssues = false; + this.isSubmitting = false; + this.promptData = { subIssuesCount, resolve }; + }); + } + + setPropagateToSubIssues = action((value: boolean) => { + this.propagateToSubIssues = value; + }); + + confirm = action(() => { + this.promptData?.resolve(this.propagateToSubIssues); + this.promptData = null; + this.isSubmitting = false; + }); + + cancel = action(() => { + this.promptData?.resolve(null); + this.promptData = null; + this.isSubmitting = false; + }); + + setIsSubmitting = action((value: boolean) => { + this.isSubmitting = value; + }); +} + +export const propagateStateStore = new PropagateStateStore(); diff --git a/packages/i18n/src/locales/cs/work-item.json b/packages/i18n/src/locales/cs/work-item.json index 2fe97ce830d..f7f685ea5b4 100644 --- a/packages/i18n/src/locales/cs/work-item.json +++ b/packages/i18n/src/locales/cs/work-item.json @@ -277,6 +277,13 @@ "success": "Podřízená pracovní položka úspěšně odebrána", "error": "Chyba při odebírání podřízené položky" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "Nemáte podřízené pracovní položky, které odpovídají použitým filtrům.", diff --git a/packages/i18n/src/locales/de/work-item.json b/packages/i18n/src/locales/de/work-item.json index 9c235f60554..5df486a6665 100644 --- a/packages/i18n/src/locales/de/work-item.json +++ b/packages/i18n/src/locales/de/work-item.json @@ -277,6 +277,13 @@ "success": "Untergeordnetes Arbeitselement erfolgreich entfernt", "error": "Fehler beim Entfernen des untergeordneten Elements" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "Sie haben keine untergeordneten Arbeitselemente, die den von Ihnen angewendeten Filtern entsprechen.", diff --git a/packages/i18n/src/locales/en/work-item.json b/packages/i18n/src/locales/en/work-item.json index b7935a125c3..6423d0959f4 100644 --- a/packages/i18n/src/locales/en/work-item.json +++ b/packages/i18n/src/locales/en/work-item.json @@ -277,6 +277,13 @@ "success": "Sub-work item removed successfully", "error": "Error removing sub-work item" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "You don't have sub-work items that match the filters you've applied.", diff --git a/packages/i18n/src/locales/es/work-item.json b/packages/i18n/src/locales/es/work-item.json index 738fff249c7..934f407175f 100644 --- a/packages/i18n/src/locales/es/work-item.json +++ b/packages/i18n/src/locales/es/work-item.json @@ -277,6 +277,13 @@ "success": "Sub-elemento eliminado correctamente", "error": "Error al eliminar el sub-elemento" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "No tienes sub-elementos de trabajo que coincidan con los filtros que has aplicado.", diff --git a/packages/i18n/src/locales/fr/work-item.json b/packages/i18n/src/locales/fr/work-item.json index 04a5958092b..e3ee78a3318 100644 --- a/packages/i18n/src/locales/fr/work-item.json +++ b/packages/i18n/src/locales/fr/work-item.json @@ -277,6 +277,13 @@ "success": "Sous-élément de travail supprimé avec succès", "error": "Erreur lors de la suppression du sous-élément de travail" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "Vous n’avez pas de sous-éléments de travail qui correspondent aux filtres que vous avez appliqués.", diff --git a/packages/i18n/src/locales/id/work-item.json b/packages/i18n/src/locales/id/work-item.json index 917fdeb1b6a..5692dbb63a4 100644 --- a/packages/i18n/src/locales/id/work-item.json +++ b/packages/i18n/src/locales/id/work-item.json @@ -277,6 +277,13 @@ "success": "Sub-item kerja berhasil dihapus", "error": "Kesalahan saat menghapus sub-item kerja" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "Anda tidak memiliki sub-item kerja yang cocok dengan filter yang Anda terapkan.", diff --git a/packages/i18n/src/locales/it/work-item.json b/packages/i18n/src/locales/it/work-item.json index 7622c815bb8..8d4faf2860f 100644 --- a/packages/i18n/src/locales/it/work-item.json +++ b/packages/i18n/src/locales/it/work-item.json @@ -277,6 +277,13 @@ "success": "Sotto-elemento di lavoro rimosso con successo", "error": "Errore nella rimozione del sotto-elemento di lavoro" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "Non hai sotto-elementi di lavoro che corrispondono ai filtri che hai applicato.", diff --git a/packages/i18n/src/locales/ja/work-item.json b/packages/i18n/src/locales/ja/work-item.json index ca6ed7ffc96..c8e8024b0f5 100644 --- a/packages/i18n/src/locales/ja/work-item.json +++ b/packages/i18n/src/locales/ja/work-item.json @@ -277,6 +277,13 @@ "success": "サブ作業項目を削除しました", "error": "サブ作業項目の削除中にエラーが発生しました" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "適用されたフィルターに一致するサブ作業項目がありません。", diff --git a/packages/i18n/src/locales/ko/work-item.json b/packages/i18n/src/locales/ko/work-item.json index 259adc2fc57..7d8d177da6b 100644 --- a/packages/i18n/src/locales/ko/work-item.json +++ b/packages/i18n/src/locales/ko/work-item.json @@ -277,6 +277,13 @@ "success": "하위 작업 항목이 성공적으로 제거되었습니다", "error": "하위 작업 항목 제거 중 오류 발생" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "적용된 필터에 일치하는 하위 작업 항목이 없습니다.", diff --git a/packages/i18n/src/locales/pl/work-item.json b/packages/i18n/src/locales/pl/work-item.json index f9c6a284957..a91b4c4554b 100644 --- a/packages/i18n/src/locales/pl/work-item.json +++ b/packages/i18n/src/locales/pl/work-item.json @@ -277,6 +277,13 @@ "success": "Podrzędny element pracy usunięto pomyślnie", "error": "Błąd podczas usuwania elementu podrzędnego" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "Nie masz elementów podrzędnych, które pasują do filtrów, które zastosowałeś.", diff --git a/packages/i18n/src/locales/pt-BR/work-item.json b/packages/i18n/src/locales/pt-BR/work-item.json index 614a34a21d8..e9e283f2899 100644 --- a/packages/i18n/src/locales/pt-BR/work-item.json +++ b/packages/i18n/src/locales/pt-BR/work-item.json @@ -277,6 +277,13 @@ "success": "Sub-item de trabalho removido com sucesso", "error": "Erro ao remover sub-item de trabalho" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "Você não tem sub-itens de trabalho que correspondem aos filtros que você aplicou.", diff --git a/packages/i18n/src/locales/ro/work-item.json b/packages/i18n/src/locales/ro/work-item.json index a989e537093..993d3f1a0ac 100644 --- a/packages/i18n/src/locales/ro/work-item.json +++ b/packages/i18n/src/locales/ro/work-item.json @@ -277,6 +277,13 @@ "success": "Sub-activitatea a fost eliminată cu succes", "error": "Eroare la eliminarea sub-activității" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "Nu ai sub-elemente de lucru care corespund filtrelor pe care le-ai aplicat.", diff --git a/packages/i18n/src/locales/ru/work-item.json b/packages/i18n/src/locales/ru/work-item.json index df676a95bd1..edd6afb04c2 100644 --- a/packages/i18n/src/locales/ru/work-item.json +++ b/packages/i18n/src/locales/ru/work-item.json @@ -277,6 +277,13 @@ "success": "Подэлемент успешно удален", "error": "Ошибка удаления подэлемента" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "У вас нет подэлементов, которые соответствуют примененным фильтрам.", diff --git a/packages/i18n/src/locales/sk/work-item.json b/packages/i18n/src/locales/sk/work-item.json index f015c780f38..31363fda1eb 100644 --- a/packages/i18n/src/locales/sk/work-item.json +++ b/packages/i18n/src/locales/sk/work-item.json @@ -277,6 +277,13 @@ "success": "Podriadená pracovná položka bola úspešne odstránená", "error": "Chyba pri odstraňovaní podriadenej položky" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "Nemáte podriadené pracovné položky, ktoré zodpovedajú použitým filtrom.", diff --git a/packages/i18n/src/locales/tr-TR/work-item.json b/packages/i18n/src/locales/tr-TR/work-item.json index 0cf70387523..1b8a9780169 100644 --- a/packages/i18n/src/locales/tr-TR/work-item.json +++ b/packages/i18n/src/locales/tr-TR/work-item.json @@ -277,6 +277,13 @@ "success": "Alt iş öğesi başarıyla kaldırıldı", "error": "Alt iş öğesi kaldırılırken hata oluştu" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "Alt iş öğelerinizin filtreleriyle eşleşmiyor.", diff --git a/packages/i18n/src/locales/ua/work-item.json b/packages/i18n/src/locales/ua/work-item.json index 158f41610b0..d23ce57a342 100644 --- a/packages/i18n/src/locales/ua/work-item.json +++ b/packages/i18n/src/locales/ua/work-item.json @@ -277,6 +277,13 @@ "success": "Похідну робочу одиницю успішно вилучено", "error": "Помилка під час вилучення похідної одиниці" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "Ви не маєте похідних робочих одиниць, які відповідають застосованим фільтрам.", diff --git a/packages/i18n/src/locales/vi-VN/work-item.json b/packages/i18n/src/locales/vi-VN/work-item.json index 848fd48ae34..ea07880c501 100644 --- a/packages/i18n/src/locales/vi-VN/work-item.json +++ b/packages/i18n/src/locales/vi-VN/work-item.json @@ -277,6 +277,13 @@ "success": "Đã xóa mục công việc con thành công", "error": "Đã xảy ra lỗi khi xóa mục công việc con" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "Bạn không có mục công việc con nào phù hợp với các bộ lọc mà bạn đã áp dụng.", diff --git a/packages/i18n/src/locales/zh-CN/work-item.json b/packages/i18n/src/locales/zh-CN/work-item.json index 0617955699e..7771fba472e 100644 --- a/packages/i18n/src/locales/zh-CN/work-item.json +++ b/packages/i18n/src/locales/zh-CN/work-item.json @@ -277,6 +277,13 @@ "success": "子工作项移除成功", "error": "移除子工作项时出错" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "您没有符合您应用的过滤器的子工作项。", diff --git a/packages/i18n/src/locales/zh-TW/work-item.json b/packages/i18n/src/locales/zh-TW/work-item.json index 450b65a1131..fb6a1e5b372 100644 --- a/packages/i18n/src/locales/zh-TW/work-item.json +++ b/packages/i18n/src/locales/zh-TW/work-item.json @@ -277,6 +277,13 @@ "success": "子工作事項移除成功", "error": "移除子工作事項時發生錯誤" }, + "propagate_state": { + "modal": { + "title": "Update sub-work items?", + "description": "This work item has {count, plural, one {# sub-work item} other {# sub-work items}}. You can apply the same state change to them.", + "toggle": "Apply state to sub-work items" + } + }, "empty_state": { "sub_list_filters": { "title": "您沒有符合您應用過的過濾器的子工作事項。", diff --git a/packages/types/src/issues/issue.ts b/packages/types/src/issues/issue.ts index 8054b4c44c3..2306afcdafe 100644 --- a/packages/types/src/issues/issue.ts +++ b/packages/types/src/issues/issue.ts @@ -103,6 +103,10 @@ export type TIssue = TBaseIssue & { state__group?: TStateGroups | null; }; +export type TIssuePatchPayload = Partial & { + propagate_state_to_sub_issues?: boolean; +}; + export type TIssueMap = { [issue_id: string]: TIssue; }; From afcf7f62bf4a2e84f54436938744987c1e7dfb29 Mon Sep 17 00:00:00 2001 From: iyamokuma Date: Fri, 14 Aug 2026 00:58:07 -0700 Subject: [PATCH 2/3] fix: resolve web type errors for state propagation helper Move helper to apps/web/helpers to match tsconfig path alias and fix afterPropagate callback return type in issue detail sidebar. Co-authored-by: Cursor --- apps/web/core/components/issues/issue-detail/sidebar.tsx | 4 +++- apps/web/{core => }/helpers/issue-state-update.ts | 0 2 files changed, 3 insertions(+), 1 deletion(-) rename apps/web/{core => }/helpers/issue-state-update.ts (100%) diff --git a/apps/web/core/components/issues/issue-detail/sidebar.tsx b/apps/web/core/components/issues/issue-detail/sidebar.tsx index b3da571ae91..efee1611220 100644 --- a/apps/web/core/components/issues/issue-detail/sidebar.tsx +++ b/apps/web/core/components/issues/issue-detail/sidebar.tsx @@ -86,7 +86,9 @@ export const IssueDetailsSidebar = observer(function IssueDetailsSidebar(props: newStateId: stateId, subIssuesCount: issue.sub_issues_count ?? 0, onUpdate: async (data) => issueOperations.update(workspaceSlug, projectId, issueId, data), - afterPropagate: async () => fetchSubIssues(workspaceSlug, projectId, issueId), + afterPropagate: async () => { + await fetchSubIssues(workspaceSlug, projectId, issueId); + }, }); }; diff --git a/apps/web/core/helpers/issue-state-update.ts b/apps/web/helpers/issue-state-update.ts similarity index 100% rename from apps/web/core/helpers/issue-state-update.ts rename to apps/web/helpers/issue-state-update.ts From ded55012b668e3cd2327948d06d026e077cc021d Mon Sep 17 00:00:00 2001 From: iyamokuma Date: Fri, 14 Aug 2026 00:59:38 -0700 Subject: [PATCH 3/3] fix: address CodeRabbit review for state propagation Validate propagate_state_to_sub_issues as a strict boolean on the API, stage drag-and-drop updates until the propagation prompt settles, log drop errors before toasts, and resolve any in-flight prompt before opening a new one. Co-authored-by: Cursor --- apps/api/plane/app/views/issue/base.py | 5 ++ apps/web/core/hooks/use-group-dragndrop.ts | 76 +++++++++++-------- .../core/store/issue/propagate-state.store.ts | 1 + 3 files changed, 52 insertions(+), 30 deletions(-) diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index ef7cddb0f24..50727715df9 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -632,6 +632,11 @@ def partial_update(self, request, slug, project_id, pk=None): skip_activity = request.data.pop("skip_activity", False) propagate_state_to_sub_issues_flag = request.data.pop("propagate_state_to_sub_issues", False) + 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 = ( diff --git a/apps/web/core/hooks/use-group-dragndrop.ts b/apps/web/core/hooks/use-group-dragndrop.ts index 802115dde26..33d8fe029b7 100644 --- a/apps/web/core/hooks/use-group-dragndrop.ts +++ b/apps/web/core/hooks/use-group-dragndrop.ts @@ -73,53 +73,69 @@ export const useGroupIssuesDragNDrop = ( const isModuleChanged = Object.keys(data).includes(moduleKey); const isCycleChanged = Object.keys(data).includes(cycleKey); - if (isCycleChanged && workspaceSlug) { - if (data[cycleKey]) { - addCycleToIssue(workspaceSlug.toString(), projectId, data[cycleKey]?.toString() ?? "", issueId).catch(() => - setToast(errorToastProps) - ); - } else { - removeCycleFromIssue(workspaceSlug.toString(), projectId, issueId).catch(() => setToast(errorToastProps)); + const issue = getIssueById(issueId); + const stateId = data.state_id; + const needsPropagationPrompt = Boolean( + stateId && issue && issue.sub_issues_count > 0 && stateId !== issue.state_id + ); + + const applyCycleAndModuleChanges = async () => { + if (!workspaceSlug) return; + + if (isCycleChanged) { + if (data[cycleKey]) { + await addCycleToIssue(workspaceSlug.toString(), projectId, data[cycleKey]?.toString() ?? "", issueId); + } else { + await removeCycleFromIssue(workspaceSlug.toString(), projectId, issueId); + } } - delete data[cycleKey]; - } - if (isModuleChanged && workspaceSlug && issueUpdates[moduleKey]) { - changeModulesInIssue( - workspaceSlug.toString(), - projectId, - issueId, - issueUpdates[moduleKey].ADD, - issueUpdates[moduleKey].REMOVE - ).catch(() => setToast(errorToastProps)); - delete data[moduleKey]; - } + if (isModuleChanged && issueUpdates[moduleKey]) { + await changeModulesInIssue( + workspaceSlug.toString(), + projectId, + issueId, + issueUpdates[moduleKey].ADD, + issueUpdates[moduleKey].REMOVE + ); + } + }; - const issue = getIssueById(issueId); - const stateId = data.state_id; + const issueUpdateData = { ...data }; + if (isCycleChanged) delete issueUpdateData[cycleKey]; + if (isModuleChanged) delete issueUpdateData[moduleKey]; - if (stateId && issue && issue.sub_issues_count > 0) { - const remainingData = { ...data }; - delete remainingData.state_id; + const applyIssueUpdate = async (patchData: Partial) => { + if (updateIssue) { + await updateIssue(projectId, issueId, patchData); + } + }; + if (needsPropagationPrompt && issue && stateId) { try { await updateIssueStateWithPropagation({ currentStateId: issue.state_id, newStateId: stateId, subIssuesCount: issue.sub_issues_count, - onUpdate: async (updateData) => { - if (updateIssue) { - await updateIssue(projectId, issueId, { ...remainingData, ...updateData }); - } + onUpdate: async (stateUpdateData) => { + await applyCycleAndModuleChanges(); + await applyIssueUpdate({ ...issueUpdateData, ...stateUpdateData }); }, }); - } catch { + } catch (error) { + console.error("Error while updating work item during drag-and-drop propagation:", error); setToast(errorToastProps); } return; } - updateIssue && updateIssue(projectId, issueId, data).catch(() => setToast(errorToastProps)); + try { + await applyCycleAndModuleChanges(); + await applyIssueUpdate(issueUpdateData); + } catch (error) { + console.error("Error while updating work item during drag-and-drop:", error); + setToast(errorToastProps); + } }; const handleOnDrop = async (source: GroupDropLocation, destination: GroupDropLocation) => { diff --git a/apps/web/core/store/issue/propagate-state.store.ts b/apps/web/core/store/issue/propagate-state.store.ts index 0e03326b721..935df5023f7 100644 --- a/apps/web/core/store/issue/propagate-state.store.ts +++ b/apps/web/core/store/issue/propagate-state.store.ts @@ -22,6 +22,7 @@ class PropagateStateStore { prompt(subIssuesCount: number): Promise { return new Promise((resolve) => { + this.promptData?.resolve(null); this.propagateToSubIssues = false; this.isSubmitting = false; this.promptData = { subIssuesCount, resolve };