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

pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 3d4b61104b4 UI: Use react-query native error state for bulk action 
hooks (#67284)
3d4b61104b4 is described below

commit 3d4b61104b4ad72c20ed3d729385c605f8d6c018
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Tue May 26 16:50:47 2026 +0200

    UI: Use react-query native error state for bulk action hooks (#67284)
    
    * UI: Use react-query native error state for bulk action hooks
    
    Both ``useBulkTaskInstances`` and ``useBulkDeleteDagRuns`` mirrored
    the same anti-pattern: a ``useState<unknown>`` field, an ``onError``
    callback that just forwarded to ``setError``, and a tiny helper that
    grabbed ``errors[0]`` from the response body and re-shaped it into
    ``{body:{detail:...}}`` so ``ErrorAlert`` could render the first
    per-entity failure. That hid every error past the first and
    duplicated mutation state into React state for no reason.
    
    Both hooks now return ``{ bulkAction, data, error, isPending, reset }``
    straight from ``useMutation``:
    
    - ``error`` covers HTTP-level failures (4xx/5xx, network).
    - ``data.delete.errors`` / ``data.update.errors`` carries the
      per-entity failures the backend returns on a 200 response (partial
      success). Consumers render every entry, not just the first.
    - ``reset`` replaces the consumer-side ``setError(undefined)`` calls.
    
    ``onSuccess`` still invalidates queries unconditionally, fires the
    toaster + clears selection when ``success.length > 0``, and only
    closes the dialog when ``errors.length === 0`` — partial-success
    keeps the dialog open so the user can read what failed.
    
    The three consumers (``BulkMarkTaskInstancesAsButton``,
    ``BulkDeleteTaskInstancesButton``, ``BulkDeleteDagRunsButton``) now
    render network errors via ``ErrorAlert`` and per-entity errors as a
    ``Stack`` of ``Alert`` rows below it.
    
    * UI: Extract ActionErrors component (Brent review)
    
    * UI: Keep bulk-action dialog open on partial success
    
    On a partial success the dialog now stays mounted so the user can read
    the per-entity errors, and only the successful rows are removed from
    the selection — clicking confirm again retries just the failed ones.
    
    The previous behaviour cleared the whole selection on any success,
    which collapsed the ActionBar (its open state is bound to
    ``selectedRows.size``) and unmounted the dialog along with it, hiding
    the errors the user was meant to see.
    
    UI row keys now use the same identifier format the bulk API echoes
    back in ``success`` / ``errors`` (``{dag_id}.{run_id}`` and
    ``{dag_id}.{run_id}.{task_id}[{map_index}]``), so the bulk hooks can
    pass the response's ``success`` array straight to a new
    ``deselectKeys`` on ``useRowSelection``.
---
 .../src/airflow/ui/src/components/ActionErrors.tsx | 46 +++++++++++++
 .../ui/src/components/DataTable/useRowSelection.ts | 14 ++++
 .../src/pages/DagRuns/BulkDeleteDagRunsButton.tsx  | 12 ++--
 .../src/airflow/ui/src/pages/DagRuns/DagRuns.tsx   |  8 ++-
 .../BulkDeleteTaskInstancesButton.tsx              | 12 ++--
 .../BulkMarkTaskInstancesAsButton.tsx              | 14 ++--
 .../ui/src/pages/TaskInstances/TaskInstances.tsx   | 11 +--
 .../airflow/ui/src/queries/useBulkDeleteDagRuns.ts | 78 +++++++++------------
 .../airflow/ui/src/queries/useBulkTaskInstances.ts | 79 ++++++++--------------
 9 files changed, 154 insertions(+), 120 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/components/ActionErrors.tsx 
b/airflow-core/src/airflow/ui/src/components/ActionErrors.tsx
new file mode 100644
index 00000000000..2b2f46d446b
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/ActionErrors.tsx
@@ -0,0 +1,46 @@
+/*!
+ * 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 { Stack } from "@chakra-ui/react";
+
+import type { BulkActionResponse } from "openapi/requests/types.gen";
+import { ErrorAlert } from "src/components/ErrorAlert";
+import { Alert } from "src/components/ui";
+
+type Props = {
+  readonly actionResponse?: BulkActionResponse | null;
+  readonly error: unknown;
+};
+
+export const ActionErrors = ({ actionResponse, error }: Props) => {
+  const actionErrors = (actionResponse?.errors ?? []) as Array<{ error: 
string; status_code?: number }>;
+
+  return (
+    <>
+      <ErrorAlert error={error} />
+      {actionErrors.length > 0 ? (
+        <Stack gap={2} mt={3}>
+          {actionErrors.map((actionError, index) => (
+            // eslint-disable-next-line react/no-array-index-key -- per-entity 
errors have no stable id
+            <Alert key={index} status="error" title={actionError.error} />
+          ))}
+        </Stack>
+      ) : undefined}
+    </>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.ts 
b/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.ts
index 174aa5548ba..fb6842747f7 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.ts
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.ts
@@ -72,9 +72,23 @@ export const useRowSelection = <T>({ data = [], getKey }: 
UseRowSelectionProps<T
     setSelectedRows(new Map());
   };
 
+  const deselectKeys = (keys: Array<string>) => {
+    if (keys.length === 0) {
+      return;
+    }
+    setSelectedRows((prev) => {
+      const newMap = new Map(prev);
+
+      keys.forEach((key) => newMap.delete(key));
+
+      return newMap;
+    });
+  };
+
   return {
     allRowsSelected,
     clearSelections,
+    deselectKeys,
     handleRowSelect,
     handleSelectAll,
     selectedRows,
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagRuns/BulkDeleteDagRunsButton.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagRuns/BulkDeleteDagRunsButton.tsx
index e1a4b9c5c35..bedd0739e6b 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns/BulkDeleteDagRunsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/BulkDeleteDagRunsButton.tsx
@@ -23,15 +23,15 @@ import { useTranslation } from "react-i18next";
 import { FiTrash2 } from "react-icons/fi";
 
 import type { DAGRunResponse } from "openapi/requests/types.gen";
+import { ActionErrors } from "src/components/ActionErrors";
 import { DataTable } from "src/components/DataTable";
-import { ErrorAlert } from "src/components/ErrorAlert";
 import { StateBadge } from "src/components/StateBadge";
 import Time from "src/components/Time";
 import { Accordion, Dialog } from "src/components/ui";
 import { useBulkDeleteDagRuns } from "src/queries/useBulkDeleteDagRuns";
 
 type Props = {
-  readonly clearSelections: VoidFunction;
+  readonly deselectKeys: (keys: Array<string>) => void;
   readonly selectedDagRuns: Array<DAGRunResponse>;
 };
 
@@ -58,11 +58,11 @@ const getColumns = (translate: TFunction): 
Array<ColumnDef<DAGRunResponse>> => [
   },
 ];
 
-const BulkDeleteDagRunsButton = ({ clearSelections, selectedDagRuns }: Props) 
=> {
+const BulkDeleteDagRunsButton = ({ deselectKeys, selectedDagRuns }: Props) => {
   const { t: translate } = useTranslation(["common", "dags"]);
   const { onClose, onOpen, open } = useDisclosure();
-  const { bulkAction, error, isPending } = useBulkDeleteDagRuns({
-    clearSelections,
+  const { bulkAction, data, error, isPending } = useBulkDeleteDagRuns({
+    deselectKeys,
     onSuccessConfirm: onClose,
   });
 
@@ -142,7 +142,7 @@ const BulkDeleteDagRunsButton = ({ clearSelections, 
selectedDagRuns }: Props) =>
               )}
             </Box>
 
-            <ErrorAlert error={error} />
+            <ActionErrors actionResponse={data?.delete} error={error} />
             <Flex justifyContent="end" mt={3}>
               <Button
                 colorPalette="danger"
diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
index 217a9e62d98..5552fcb8ada 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
@@ -48,7 +48,9 @@ import BulkDeleteDagRunsButton from 
"./BulkDeleteDagRunsButton";
 import { DagRunsFilters } from "./DagRunsFilters";
 import DeleteRunButton from "./DeleteRunButton";
 
-const getRowKey = (dagRun: DAGRunResponse) => 
`${dagRun.dag_id}:${dagRun.dag_run_id}`;
+// Matches the identifier the bulk Dag Run endpoint echoes back in its 
``success`` /
+// ``errors`` lists, so the bulk response can deselect rows directly.
+const getRowKey = (dagRun: DAGRunResponse) => 
`${dagRun.dag_id}.${dagRun.dag_run_id}`;
 
 type DagRunRow = { row: { original: DAGRunResponse } };
 const {
@@ -333,7 +335,7 @@ export const DagRuns = () => {
   const nextCursor = data?.next_cursor ?? undefined;
   const previousCursor = data?.previous_cursor ?? undefined;
 
-  const { allRowsSelected, clearSelections, handleRowSelect, handleSelectAll, 
selectedRows } =
+  const { allRowsSelected, clearSelections, deselectKeys, handleRowSelect, 
handleSelectAll, selectedRows } =
     useRowSelection({
       data: data?.dag_runs,
       getKey: getRowKey,
@@ -371,7 +373,7 @@ export const DagRuns = () => {
             {selectedRows.size} {translate("selected")}
           </ActionBar.SelectionTrigger>
           <ActionBar.Separator />
-          <BulkDeleteDagRunsButton clearSelections={clearSelections} 
selectedDagRuns={selectedDagRuns} />
+          <BulkDeleteDagRunsButton deselectKeys={deselectKeys} 
selectedDagRuns={selectedDagRuns} />
           <ActionBar.CloseTrigger onClick={clearSelections} />
         </ActionBar.Content>
       </ActionBar.Root>
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/BulkDeleteTaskInstancesButton.tsx
 
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/BulkDeleteTaskInstancesButton.tsx
index 8e752907de9..3d3ed2456ed 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/BulkDeleteTaskInstancesButton.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/BulkDeleteTaskInstancesButton.tsx
@@ -22,21 +22,21 @@ import { FiTrash2 } from "react-icons/fi";
 
 import type { TaskInstanceResponse } from "openapi/requests/types.gen";
 import { getColumns } from "src/components/ActionAccordion/columns";
+import { ActionErrors } from "src/components/ActionErrors";
 import { DataTable } from "src/components/DataTable";
-import { ErrorAlert } from "src/components/ErrorAlert";
 import { Accordion, Dialog } from "src/components/ui";
 import { useBulkTaskInstances } from "src/queries/useBulkTaskInstances";
 
 type Props = {
-  readonly clearSelections: VoidFunction;
+  readonly deselectKeys: (keys: Array<string>) => void;
   readonly selectedTaskInstances: Array<TaskInstanceResponse>;
 };
 
-const BulkDeleteTaskInstancesButton = ({ clearSelections, 
selectedTaskInstances }: Props) => {
+const BulkDeleteTaskInstancesButton = ({ deselectKeys, selectedTaskInstances 
}: Props) => {
   const { t: translate } = useTranslation();
   const { onClose, onOpen, open } = useDisclosure();
-  const { bulkAction, error, isPending } = useBulkTaskInstances({
-    clearSelections,
+  const { bulkAction, data, error, isPending } = useBulkTaskInstances({
+    deselectKeys,
     onSuccessConfirm: onClose,
   });
 
@@ -117,7 +117,7 @@ const BulkDeleteTaskInstancesButton = ({ clearSelections, 
selectedTaskInstances
               )}
             </Box>
 
-            <ErrorAlert error={error} />
+            <ActionErrors actionResponse={data?.delete} error={error} />
             <Flex justifyContent="end" mt={3}>
               <Button
                 colorPalette="danger"
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/BulkMarkTaskInstancesAsButton.tsx
 
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/BulkMarkTaskInstancesAsButton.tsx
index 41eb7c9e730..43084c4b221 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/BulkMarkTaskInstancesAsButton.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/BulkMarkTaskInstancesAsButton.tsx
@@ -24,7 +24,7 @@ import { LuCheck } from "react-icons/lu";
 
 import type { TaskInstanceResponse, TaskInstanceState } from 
"openapi/requests/types.gen";
 import { ActionAccordion } from "src/components/ActionAccordion";
-import { ErrorAlert } from "src/components/ErrorAlert";
+import { ActionErrors } from "src/components/ActionErrors";
 import { allowedStates } from "src/components/MarkAs/utils";
 import { StateBadge } from "src/components/StateBadge";
 import { Dialog, Menu } from "src/components/ui";
@@ -33,18 +33,18 @@ import { useBulkMarkAsDryRun } from 
"src/queries/useBulkMarkAsDryRun";
 import { useBulkTaskInstances } from "src/queries/useBulkTaskInstances";
 
 type Props = {
-  readonly clearSelections: VoidFunction;
+  readonly deselectKeys: (keys: Array<string>) => void;
   readonly selectedTaskInstances: Array<TaskInstanceResponse>;
 };
 
-const BulkMarkTaskInstancesAsButton = ({ clearSelections, 
selectedTaskInstances }: Props) => {
+const BulkMarkTaskInstancesAsButton = ({ deselectKeys, selectedTaskInstances 
}: Props) => {
   const { t: translate } = useTranslation();
   const { onClose, onOpen, open } = useDisclosure();
   const [state, setState] = useState<TaskInstanceState>("success");
   const [selectedOptions, setSelectedOptions] = useState<Array<string>>([]);
   const [note, setNote] = useState<string | null>(null);
-  const { bulkAction, error, isPending, setError } = useBulkTaskInstances({
-    clearSelections,
+  const { bulkAction, data, error, isPending, reset } = useBulkTaskInstances({
+    deselectKeys,
     onSuccessConfirm: onClose,
   });
 
@@ -73,7 +73,7 @@ const BulkMarkTaskInstancesAsButton = ({ clearSelections, 
selectedTaskInstances
     setState(newState);
     setSelectedOptions([]);
     setNote(null);
-    setError(undefined);
+    reset();
     onOpen();
   };
 
@@ -163,7 +163,7 @@ const BulkMarkTaskInstancesAsButton = ({ clearSelections, 
selectedTaskInstances
               />
             </Flex>
             <ActionAccordion affectedTasks={affectedTasks} groupByRunId 
note={note} setNote={setNote} />
-            <ErrorAlert error={error} />
+            <ActionErrors actionResponse={data?.update} error={error} />
             <Flex justifyContent="end" mt={3}>
               <Button
                 disabled={affectedTasks.total_entries === 0}
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
index ddba57c95c8..b1a8a4aa9e4 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
@@ -50,7 +50,10 @@ import { TaskInstancesFilter } from "./TaskInstancesFilter";
 
 type TaskInstanceRow = { row: { original: TaskInstanceResponse } };
 
-const getRowKey = (ti: TaskInstanceResponse) => 
`${ti.dag_id}:${ti.dag_run_id}:${ti.task_id}:${ti.map_index}`;
+// Matches the identifier the bulk task-instance endpoint echoes back in its
+// ``success`` / ``errors`` lists, so the bulk response can deselect rows 
directly.
+const getRowKey = (ti: TaskInstanceResponse) =>
+  `${ti.dag_id}.${ti.dag_run_id}.${ti.task_id}[${ti.map_index}]`;
 
 const {
   DAG_ID_PATTERN: DAG_ID_PATTERN_PARAM,
@@ -369,7 +372,7 @@ export const TaskInstances = () => {
   const nextCursor = data?.next_cursor ?? undefined;
   const previousCursor = data?.previous_cursor ?? undefined;
 
-  const { allRowsSelected, clearSelections, handleRowSelect, handleSelectAll, 
selectedRows } =
+  const { allRowsSelected, clearSelections, deselectKeys, handleRowSelect, 
handleSelectAll, selectedRows } =
     useRowSelection({
       data: data?.task_instances,
       getKey: getRowKey,
@@ -414,11 +417,11 @@ export const TaskInstances = () => {
             selectedTaskInstances={selectedTaskInstances}
           />
           <BulkMarkTaskInstancesAsButton
-            clearSelections={clearSelections}
+            deselectKeys={deselectKeys}
             selectedTaskInstances={selectedTaskInstances}
           />
           <BulkDeleteTaskInstancesButton
-            clearSelections={clearSelections}
+            deselectKeys={deselectKeys}
             selectedTaskInstances={selectedTaskInstances}
           />
           <ActionBar.CloseTrigger onClick={clearSelections} />
diff --git a/airflow-core/src/airflow/ui/src/queries/useBulkDeleteDagRuns.ts 
b/airflow-core/src/airflow/ui/src/queries/useBulkDeleteDagRuns.ts
index d0a3fcff978..b117dd1364e 100644
--- a/airflow-core/src/airflow/ui/src/queries/useBulkDeleteDagRuns.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useBulkDeleteDagRuns.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 import { useQueryClient } from "@tanstack/react-query";
-import { useRef, useState } from "react";
+import { useRef } from "react";
 import { useTranslation } from "react-i18next";
 
 import {
@@ -25,35 +25,18 @@ import {
   useDagRunServiceGetDagRunsKey,
   useTaskInstanceServiceGetTaskInstancesKey,
 } from "openapi/queries";
-import type { BulkActionResponse, BulkBody_BulkDAGRunBody_, BulkResponse } 
from "openapi/requests/types.gen";
+import type { BulkBody_BulkDAGRunBody_, BulkResponse } from 
"openapi/requests/types.gen";
 import { toaster } from "src/components/ui";
 
 import { gridQueryKeys, tiPerAttemptQueryKeys } from "./gridViewQueryKeys";
 
 type Props = {
-  readonly clearSelections: VoidFunction;
+  readonly deselectKeys: (keys: Array<string>) => void;
   readonly onSuccessConfirm: VoidFunction;
 };
 
-const handleActionResult = (
-  actionResult: BulkActionResponse,
-  setError: (error: unknown) => void,
-  onSuccess: (count: number, keys: Array<string>) => void,
-) => {
-  const { errors, success } = actionResult;
-
-  if (Array.isArray(errors) && errors.length > 0) {
-    const apiError = errors[0] as { error: string };
-
-    setError({ body: { detail: apiError.error } });
-  } else if (Array.isArray(success) && success.length > 0) {
-    onSuccess(success.length, success);
-  }
-};
-
-export const useBulkDeleteDagRuns = ({ clearSelections, onSuccessConfirm }: 
Props) => {
+export const useBulkDeleteDagRuns = ({ deselectKeys, onSuccessConfirm }: 
Props) => {
   const queryClient = useQueryClient();
-  const [error, setError] = useState<unknown>(undefined);
   const affectedDagIds = useRef<Set<string>>(new Set());
   const { t: translate } = useTranslation(["common", "dags"]);
 
@@ -67,36 +50,41 @@ export const useBulkDeleteDagRuns = ({ clearSelections, 
onSuccessConfirm }: Prop
       ),
     ]);
 
-    if (responseData.delete) {
-      handleActionResult(responseData.delete, setError, (count, keys) => {
-        toaster.create({
-          description: translate("toaster.bulkDelete.success.description", {
-            count,
-            keys: keys.join(", "),
-            resourceName: translate("dagRun_other"),
-          }),
-          title: translate("toaster.bulkDelete.success.title", {
-            resourceName: translate("dagRun_other"),
-          }),
-          type: "success",
-        });
-        clearSelections();
-        onSuccessConfirm();
+    const deleteResult = responseData.delete;
+
+    if (!deleteResult) {
+      return;
+    }
+
+    const successKeys = deleteResult.success ?? [];
+    const actionErrors = deleteResult.errors ?? [];
+
+    if (successKeys.length > 0) {
+      toaster.create({
+        description: translate("toaster.bulkDelete.success.description", {
+          count: successKeys.length,
+          keys: successKeys.join(", "),
+          resourceName: translate("dagRun_other"),
+        }),
+        title: translate("toaster.bulkDelete.success.title", {
+          resourceName: translate("dagRun_other"),
+        }),
+        type: "success",
       });
+      deselectKeys(successKeys);
     }
-  };
 
-  const onError = (_error: unknown) => {
-    setError(_error);
+    // Per-entity failures (status 200 with items in ``errors``) keep the 
dialog open
+    // so the user can see what failed; the consumer renders 
``data.delete.errors``.
+    if (actionErrors.length === 0) {
+      onSuccessConfirm();
+    }
   };
 
-  const { isPending, mutate } = useDagRunServiceBulkDagRuns({
-    onError,
-    onSuccess,
-  });
+  const { data, error, isPending, mutate, reset } = 
useDagRunServiceBulkDagRuns({ onSuccess });
 
   const bulkAction = (requestBody: BulkBody_BulkDAGRunBody_) => {
-    setError(undefined);
+    reset();
     const dagIds = new Set<string>();
 
     for (const action of requestBody.actions) {
@@ -110,5 +98,5 @@ export const useBulkDeleteDagRuns = ({ clearSelections, 
onSuccessConfirm }: Prop
     mutate({ dagId: "~", requestBody });
   };
 
-  return { bulkAction, error, isPending, setError };
+  return { bulkAction, data, error, isPending, reset };
 };
diff --git a/airflow-core/src/airflow/ui/src/queries/useBulkTaskInstances.ts 
b/airflow-core/src/airflow/ui/src/queries/useBulkTaskInstances.ts
index ac47c550b37..bac197cf1e3 100644
--- a/airflow-core/src/airflow/ui/src/queries/useBulkTaskInstances.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useBulkTaskInstances.ts
@@ -17,7 +17,6 @@
  * under the License.
  */
 import { useQueryClient } from "@tanstack/react-query";
-import { useState } from "react";
 import { useTranslation } from "react-i18next";
 
 import {
@@ -25,39 +24,18 @@ import {
   useTaskInstanceServiceBulkTaskInstances,
   useTaskInstanceServiceGetTaskInstancesKey,
 } from "openapi/queries";
-import type {
-  BulkActionResponse,
-  BulkBody_BulkTaskInstanceBody_,
-  BulkResponse,
-} from "openapi/requests/types.gen";
+import type { BulkBody_BulkTaskInstanceBody_, BulkResponse } from 
"openapi/requests/types.gen";
 import { toaster } from "src/components/ui";
 
 import { tiPerAttemptQueryKeys } from "./gridViewQueryKeys";
 
 type Props = {
-  readonly clearSelections: VoidFunction;
+  readonly deselectKeys: (keys: Array<string>) => void;
   readonly onSuccessConfirm: VoidFunction;
 };
 
-const handleActionResult = (
-  actionResult: BulkActionResponse,
-  setError: (error: unknown) => void,
-  onSuccess: (count: number, keys: Array<string>) => void,
-) => {
-  const { errors, success } = actionResult;
-
-  if (Array.isArray(errors) && errors.length > 0) {
-    const apiError = errors[0] as { error: string };
-
-    setError({ body: { detail: apiError.error } });
-  } else if (Array.isArray(success) && success.length > 0) {
-    onSuccess(success.length, success);
-  }
-};
-
-export const useBulkTaskInstances = ({ clearSelections, onSuccessConfirm }: 
Props) => {
+export const useBulkTaskInstances = ({ deselectKeys, onSuccessConfirm }: 
Props) => {
   const queryClient = useQueryClient();
-  const [error, setError] = useState<unknown>(undefined);
   const { t: translate } = useTranslation(["common", "dags"]);
 
   const onSuccess = async (responseData: BulkResponse) => {
@@ -71,38 +49,41 @@ export const useBulkTaskInstances = ({ clearSelections, 
onSuccessConfirm }: Prop
     const actionResult = responseData.delete ?? responseData.update;
     const toasterKey = isDelete ? "toaster.bulkDelete" : "toaster.bulkUpdate";
 
-    if (actionResult) {
-      handleActionResult(actionResult, setError, (count, keys) => {
-        toaster.create({
-          description: translate(`${toasterKey}.success.description`, {
-            count,
-            keys: keys.join(", "),
-            resourceName: translate("taskInstance_other"),
-          }),
-          title: translate(`${toasterKey}.success.title`, {
-            resourceName: translate("taskInstance_other"),
-          }),
-          type: "success",
-        });
-        clearSelections();
-        onSuccessConfirm();
+    if (!actionResult) {
+      return;
+    }
+
+    const successKeys = actionResult.success ?? [];
+    const actionErrors = actionResult.errors ?? [];
+
+    if (successKeys.length > 0) {
+      toaster.create({
+        description: translate(`${toasterKey}.success.description`, {
+          count: successKeys.length,
+          keys: successKeys.join(", "),
+          resourceName: translate("taskInstance_other"),
+        }),
+        title: translate(`${toasterKey}.success.title`, {
+          resourceName: translate("taskInstance_other"),
+        }),
+        type: "success",
       });
+      deselectKeys(successKeys);
     }
-  };
 
-  const onError = (_error: unknown) => {
-    setError(_error);
+    // Per-entity failures (status 200 with items in ``errors``) keep the 
dialog open
+    // so the user can see what failed; the consumer renders 
``data.<action>.errors``.
+    if (actionErrors.length === 0) {
+      onSuccessConfirm();
+    }
   };
 
-  const { isPending, mutate } = useTaskInstanceServiceBulkTaskInstances({
-    onError,
-    onSuccess,
-  });
+  const { data, error, isPending, mutate, reset } = 
useTaskInstanceServiceBulkTaskInstances({ onSuccess });
 
   const bulkAction = (requestBody: BulkBody_BulkTaskInstanceBody_) => {
-    setError(undefined);
+    reset();
     mutate({ dagId: "~", dagRunId: "~", requestBody });
   };
 
-  return { bulkAction, error, isPending, setError };
+  return { bulkAction, data, error, isPending, reset };
 };

Reply via email to