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>