This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new 6494b4e2802 [v3-4-test] Show errors when the task clear confirmation 
check fails (#75269) (#75331)
6494b4e2802 is described below

commit 6494b4e28028bb0a0882ec085910fc82ed19c2ee
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Oct 21 22:05:41 2026 -0400

    [v3-4-test] Show errors when the task clear confirmation check fails 
(#75269) (#75331)
    
    (cherry picked from commit dcfaae090a638b2d19ac9688b041ce75b858faa1)
    
    Co-authored-by: Anzin Mohammed <[email protected]>
---
 .../ClearTaskInstanceConfirmationDialog.test.tsx   | 185 +++++++++++++++++++++
 .../ClearTaskInstanceConfirmationDialog.tsx        |  20 ++-
 2 files changed, 202 insertions(+), 3 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.test.tsx
 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.test.tsx
new file mode 100644
index 00000000000..561374c4a89
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.test.tsx
@@ -0,0 +1,185 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { ReactNode } from "react";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import "@testing-library/jest-dom/vitest";
+import { fireEvent, render, screen, waitFor, within } from 
"@testing-library/react";
+import { http, HttpResponse } from "msw";
+import { afterEach, expect, it, vi } from "vitest";
+
+import type { ClearTaskInstancesBody, TaskInstanceResponse } from 
"openapi/requests/types.gen";
+
+import {
+  CLEAR_PREVENT_RUNNING_TASK_KEY,
+  CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
+} from "src/constants/localStorage";
+import { server } from "src/mocks/server";
+import { useClearTaskInstancesDryRunKey } from 
"src/queries/useClearTaskInstancesDryRun";
+import { Wrapper } from "src/utils/Wrapper";
+
+import ClearTaskInstanceConfirmationDialog from 
"./ClearTaskInstanceConfirmationDialog";
+import ClearTaskInstanceDialog from "./ClearTaskInstanceDialog";
+
+const DAG_ID = "clear_error_test";
+const DAG_RUN_ID = "run_1";
+const ENDPOINT = `/api/v2/dags/${DAG_ID}/clearTaskInstances`;
+const mutateMock = vi.fn();
+
+vi.mock("src/queries/useClearTaskInstances", () => ({
+  useClearTaskInstances: () => ({ isPending: false, mutate: mutateMock }),
+}));
+
+const taskInstance: TaskInstanceResponse = {
+  dag_display_name: DAG_ID,
+  dag_id: DAG_ID,
+  dag_run_id: DAG_RUN_ID,
+  dag_version: null,
+  duration: null,
+  end_date: null,
+  executor: null,
+  executor_config: "{}",
+  hostname: null,
+  id: "task-a",
+  in_loop: false,
+  logical_date: "2025-01-01T00:00:00Z",
+  map_index: -1,
+  max_tries: 0,
+  note: null,
+  operator: "EmptyOperator",
+  operator_name: "EmptyOperator",
+  pid: null,
+  pool: "default_pool",
+  pool_slots: 1,
+  priority_weight: null,
+  queue: null,
+  queued_when: null,
+  rendered_fields: undefined,
+  rendered_map_index: null,
+  run_after: "2025-01-01T00:00:00Z",
+  scheduled_when: null,
+  start_date: null,
+  state: "success",
+  task_display_name: "A",
+  task_id: "A",
+  trigger: null,
+  triggerer_job: null,
+  try_number: 1,
+  unixname: null,
+};
+const affectedTasks = {
+  task_instances: [
+    taskInstance,
+    { ...taskInstance, id: "task-b", task_display_name: "B", task_id: "B" },
+    { ...taskInstance, id: "task-c", task_display_name: "C", task_id: "C" },
+  ],
+  total_entries: 3,
+};
+
+const createWrapper = (queryClient: QueryClient) => {
+  const TestWrapper = ({ children }: { readonly children: ReactNode }) => (
+    <Wrapper>
+      <QueryClientProvider 
client={queryClient}>{children}</QueryClientProvider>
+    </Wrapper>
+  );
+
+  return TestWrapper;
+};
+
+const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { 
retry: false } } });
+
+afterEach(() => {
+  localStorage.clear();
+  mutateMock.mockClear();
+});
+
+it.each(["network", "server"] as const)(
+  "shows a dismissible error when the confirmation preview has a %s failure 
after excluding a task",
+  async (failure) => {
+    localStorage.setItem(CLEAR_PREVENT_RUNNING_TASK_KEY, JSON.stringify(true));
+    localStorage.setItem(CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY, 
JSON.stringify(["downstream"]));
+    const confirmationRequests: Array<ClearTaskInstancesBody> = [];
+
+    server.use(
+      http.get(`/api/v2/dags/${DAG_ID}/details`, () => HttpResponse.json({})),
+      http.get(`/api/v2/dags/${DAG_ID}/dagRuns/${DAG_RUN_ID}`, () => 
HttpResponse.json({ dag_versions: [] })),
+      http.post(ENDPOINT, async ({ request }) => {
+        const body = (await request.json()) as ClearTaskInstancesBody;
+
+        if (body.task_instance_ids !== undefined) {
+          confirmationRequests.push(body);
+
+          return failure === "network"
+            ? HttpResponse.error()
+            : HttpResponse.json({ detail: "Preview unavailable" }, { status: 
500 });
+        }
+
+        return HttpResponse.json(affectedTasks);
+      }),
+    );
+    render(<ClearTaskInstanceDialog onClose={vi.fn()} open 
taskInstance={taskInstance} />, {
+      wrapper: createWrapper(createQueryClient()),
+    });
+
+    const excludedRow = (await screen.findByText("B")).closest("tr");
+
+    expect(excludedRow).not.toBeNull();
+    fireEvent.click(within(excludedRow as HTMLElement).getByRole("checkbox"));
+    fireEvent.click(screen.getByRole("button", { name: /modal\.confirm/iu }));
+
+    const error = await screen.findByTestId("error-alert");
+
+    expect(error).toHaveTextContent(failure === "network" ? /network error/iu 
: "Preview unavailable");
+    expect(confirmationRequests).toEqual([
+      expect.objectContaining({ dry_run: true, task_instance_ids: ["task-a", 
"task-c"] }),
+    ]);
+    expect(mutateMock).not.toHaveBeenCalled();
+    fireEvent.click(screen.getByRole("button", { name: /modal\.cancel/iu }));
+    await waitFor(() => 
expect(screen.queryByTestId("error-alert")).not.toBeInTheDocument());
+    expect(mutateMock).not.toHaveBeenCalled();
+  },
+);
+
+it("does not clear using cached preview data when its confirmation refetch 
fails", async () => {
+  const queryClient = createQueryClient();
+  const requestBody = { dag_run_id: DAG_RUN_ID, task_ids: ["A"] };
+  const onConfirm = vi.fn();
+  const onClose = vi.fn();
+
+  queryClient.setQueryData([useClearTaskInstancesDryRunKey, DAG_ID, 
requestBody], affectedTasks);
+  server.use(
+    http.post(ENDPOINT, () => HttpResponse.json({ detail: "Preview 
unavailable" }, { status: 500 })),
+  );
+  render(
+    <ClearTaskInstanceConfirmationDialog
+      dryRun={{ dagId: DAG_ID, requestBody }}
+      onClose={onClose}
+      onConfirm={onConfirm}
+      open
+      preventRunningTask
+    />,
+    { wrapper: createWrapper(queryClient) },
+  );
+
+  expect(await screen.findByTestId("error-alert")).toHaveTextContent("Preview 
unavailable");
+  expect(onConfirm).not.toHaveBeenCalled();
+  expect(onClose).not.toHaveBeenCalled();
+  fireEvent.click(screen.getByRole("button", { name: /modal\.cancel/iu }));
+  expect(onClose).toHaveBeenCalledOnce();
+});
diff --git 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.tsx
 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.tsx
index bfab29da32b..350db0a6f36 100644
--- 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.tsx
@@ -26,6 +26,8 @@ import type { ClearTaskInstancesBody } from 
"openapi/requests/types.gen";
 
 import { Dialog } from "src/system-components";
 
+import { ErrorAlert } from "src/components/ErrorAlert";
+
 import { useClearTaskInstancesDryRun } from 
"src/queries/useClearTaskInstancesDryRun";
 import { useDurationFormat } from "src/utils";
 
@@ -49,7 +51,7 @@ const ClearTaskInstanceConfirmationDialog = ({
 }: Props) => {
   const { t: translate } = useTranslation();
   const { formatRelative } = useDurationFormat();
-  const { data, isFetching } = useClearTaskInstancesDryRun({
+  const { data, error, isFetching } = useClearTaskInstancesDryRun({
     dagId: dryRun?.dagId ?? "",
     options: {
       enabled: open && Boolean(dryRun),
@@ -68,7 +70,7 @@ const ClearTaskInstanceConfirmationDialog = ({
   const taskCurrentState = firstInstance?.state;
 
   useEffect(() => {
-    if (!isFetching && open && data) {
+    if (!isFetching && !Boolean(error) && open && data) {
       const isInTriggeringState = taskCurrentState === "queued" || 
taskCurrentState === "scheduled";
 
       if (!preventRunningTask || !isInTriggeringState) {
@@ -78,7 +80,7 @@ const ClearTaskInstanceConfirmationDialog = ({
         setIsReady(true);
       }
     }
-  }, [isFetching, data, open, taskCurrentState, preventRunningTask, onConfirm, 
onClose]);
+  }, [isFetching, error, data, open, taskCurrentState, preventRunningTask, 
onConfirm, onClose]);
 
   // This dialog opens as the clear dialog closes beneath it, and that close 
would dismiss it
   // twice over: removing a dialog layer dismisses the layers stacked above 
it, and the clear
@@ -100,6 +102,18 @@ const ClearTaskInstanceConfirmationDialog = ({
               {translate("common:task.documentation")}
             </Text>
           </VStack>
+        ) : Boolean(error) ? (
+          <>
+            <Dialog.Header>
+              
<Dialog.Title>{translate("dags:runAndTaskActions.confirmationDialog.title")}</Dialog.Title>
+            </Dialog.Header>
+            <Dialog.Body>
+              <ErrorAlert error={error} />
+            </Dialog.Body>
+            <Dialog.Footer>
+              <Button 
onClick={onClose}>{translate("common:modal.cancel")}</Button>
+            </Dialog.Footer>
+          </>
         ) : isReady ? (
           <>
             <Dialog.Header>

Reply via email to