diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py
index 3b8b05a6a2f..50727715df9 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,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)
+ 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 = (
@@ -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)
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..efee1611220 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,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 (
<>
@@ -86,7 +101,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/hooks/use-group-dragndrop.ts b/apps/web/core/hooks/use-group-dragndrop.ts
index b21cdcbe10d..33d8fe029b7 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";
@@ -72,29 +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)
+ 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);
+ }
+ }
+
+ if (isModuleChanged && issueUpdates[moduleKey]) {
+ await changeModulesInIssue(
+ workspaceSlug.toString(),
+ projectId,
+ issueId,
+ issueUpdates[moduleKey].ADD,
+ issueUpdates[moduleKey].REMOVE
);
- } else {
- removeCycleFromIssue(workspaceSlug.toString(), projectId, issueId).catch(() => setToast(errorToastProps));
}
- delete data[cycleKey];
- }
+ };
+
+ const issueUpdateData = { ...data };
+ if (isCycleChanged) delete issueUpdateData[cycleKey];
+ if (isModuleChanged) delete issueUpdateData[moduleKey];
- if (isModuleChanged && workspaceSlug && issueUpdates[moduleKey]) {
- changeModulesInIssue(
- workspaceSlug.toString(),
- projectId,
- issueId,
- issueUpdates[moduleKey].ADD,
- issueUpdates[moduleKey].REMOVE
- ).catch(() => setToast(errorToastProps));
- delete data[moduleKey];
+ 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 (stateUpdateData) => {
+ await applyCycleAndModuleChanges();
+ await applyIssueUpdate({ ...issueUpdateData, ...stateUpdateData });
+ },
+ });
+ } 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
new file mode 100644
index 00000000000..935df5023f7
--- /dev/null
+++ b/apps/web/core/store/issue/propagate-state.store.ts
@@ -0,0 +1,53 @@
+/**
+ * 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.promptData?.resolve(null);
+ 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/apps/web/helpers/issue-state-update.ts b/apps/web/helpers/issue-state-update.ts
new file mode 100644
index 00000000000..867bbd99a20
--- /dev/null
+++ b/apps/web/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/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;
};