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

bbovenzi 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 3045451b8b0 Treat an empty Dag run config as no config in the UI 
(#74083)
3045451b8b0 is described below

commit 3045451b8b0ae9a41481f679b63320ebda042199
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Fri Oct 2 16:27:15 2026 +0200

    Treat an empty Dag run config as no config in the UI (#74083)
    
    A Dag run triggered without a config comes back from the API as an empty
    object `{}`, not null or undefined, so presence checks that compared against
    `undefined` treated those runs as if they carried a config: the trigger
    button opened a config menu and the run details showed `{}` for runs that
    had none. Check for a non-empty object instead, through a shared helper so
    every config-presence site agrees.
---
 .../TriggerDag/TriggerDAGButton.test.tsx           | 112 +++++++++++++++++++++
 .../src/components/TriggerDag/TriggerDAGButton.tsx |   7 +-
 .../src/airflow/ui/src/pages/DagRuns/DagRuns.tsx   |   4 +-
 .../src/airflow/ui/src/pages/Run/Details.tsx       |   4 +-
 .../utils/{index.ts => hasDagRunConfig.test.ts}    |  25 +++--
 .../ui/src/utils/{index.ts => hasDagRunConfig.ts}  |  16 +--
 airflow-core/src/airflow/ui/src/utils/index.ts     |   1 +
 7 files changed, 141 insertions(+), 28 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.test.tsx
 
b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.test.tsx
new file mode 100644
index 00000000000..15294659c4d
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.test.tsx
@@ -0,0 +1,112 @@
+/*!
+ * 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 { PropsWithChildren } from "react";
+
+import "@testing-library/jest-dom";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { TriggerDAGButton } from "./TriggerDAGButton";
+
+const wrapper = ({ children }: PropsWithChildren) => (
+  <BaseWrapper>
+    <MemoryRouter>{children}</MemoryRouter>
+  </BaseWrapper>
+);
+
+const routeParams: Record<string, string> = {};
+
+vi.mock("react-router-dom", async () => {
+  const actual = await vi.importActual("react-router-dom");
+
+  return {
+    ...actual,
+    useParams: () => routeParams,
+  };
+});
+
+const useDagRunServiceGetDagRunMock = vi.hoisted(() => vi.fn());
+
+vi.mock("openapi/queries", () => ({
+  useDagRunServiceGetDagRun: useDagRunServiceGetDagRunMock,
+}));
+
+vi.mock("react-i18next", () => ({
+  useTranslation: () => ({
+    i18n: { language: "en" },
+    // eslint-disable-next-line id-length
+    t: (translationKey: string) =>
+      ({
+        "triggerDag.button": "Trigger",
+        "triggerDag.manualRunDenied": "Manual runs are not allowed for this 
Dag",
+        "triggerDag.title": "Trigger Dag",
+        "triggerDag.triggerAgainWithConfig": "Trigger again with this config",
+      })[translationKey] ?? translationKey,
+  }),
+}));
+
+vi.mock("./TriggerDAGModal", () => ({
+  default: ({ open }: { readonly open: boolean }) =>
+    open ? <div data-testid="trigger-modal">Trigger Modal</div> : null,
+}));
+
+const props = { dagDisplayName: "My Dag", dagId: "my_dag", isPaused: false };
+
+afterEach(() => {
+  cleanup();
+  routeParams.runId = "";
+});
+
+beforeEach(() => {
+  useDagRunServiceGetDagRunMock.mockReset();
+  useDagRunServiceGetDagRunMock.mockReturnValue({ data: undefined });
+});
+
+describe("TriggerDAGButton", () => {
+  it("opens the form directly, with no config menu, when the selected run has 
an empty config", () => {
+    routeParams.runId = "run_empty_conf";
+    useDagRunServiceGetDagRunMock.mockReturnValue({
+      data: { conf: {}, dag_run_id: "run_empty_conf", logical_date: 
"2026-01-01T00:00:00Z" },
+    });
+
+    render(<TriggerDAGButton {...props} withText />, { wrapper });
+
+    fireEvent.click(screen.getByTestId("trigger-dag-button"));
+
+    expect(screen.getByTestId("trigger-modal")).toBeInTheDocument();
+    expect(screen.queryByText("Trigger again with this 
config")).not.toBeInTheDocument();
+  });
+
+  it("shows the config menu when the selected run has a non-empty config", 
async () => {
+    routeParams.runId = "run_with_conf";
+    useDagRunServiceGetDagRunMock.mockReturnValue({
+      data: { conf: { country: "FR" }, dag_run_id: "run_with_conf", 
logical_date: "2026-01-01T00:00:00Z" },
+    });
+
+    render(<TriggerDAGButton {...props} withText />, { wrapper });
+
+    fireEvent.click(screen.getByTestId("trigger-dag-button"));
+
+    expect(await screen.findByText("Trigger again with this 
config")).toBeInTheDocument();
+    expect(screen.queryByTestId("trigger-modal")).not.toBeInTheDocument();
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx 
b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx
index 82562c56555..0309821e09e 100644
--- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx
@@ -28,6 +28,8 @@ import { useDagRunServiceGetDagRun } from "openapi/queries";
 
 import { IconButton, Menu, Tooltip } from "src/system-components";
 
+import { hasDagRunConfig } from "src/utils";
+
 import TriggerDAGModal from "./TriggerDAGModal";
 
 type TriggerDAGButtonProps = {
@@ -92,8 +94,9 @@ export const TriggerDAGButton = ({
     onClose();
   };
 
-  // If there's a selected DAG Run with config, show menu with options
-  if (selectedDagRun?.conf !== undefined) {
+  // If the selected Dag run carries a non-empty config, show the menu with 
options. A run triggered
+  // without a config comes back as an empty object `{}`, which must not count 
as "has config".
+  if (hasDagRunConfig(selectedDagRun?.conf)) {
     return (
       <Box>
         <Menu.Root>
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 c826120fa88..456cbf2af8d 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
@@ -53,7 +53,7 @@ import { TruncatedText } from "src/components/TruncatedText";
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch";
 import { useConfig } from "src/queries/useConfig";
-import { useAutoRefresh, isStatePending, useDocumentTitle } from "src/utils";
+import { useAutoRefresh, hasDagRunConfig, isStatePending, useDocumentTitle } 
from "src/utils";
 
 import BulkClearDagRunsButton from "./BulkClearDagRunsButton";
 import BulkDeleteDagRunsButton from "./BulkDeleteDagRunsButton";
@@ -215,7 +215,7 @@ const runColumns = ({ dagId, multiTeam, open, translate }: 
ColumnProps): Array<C
   {
     accessorKey: "conf",
     cell: ({ row: { original } }) =>
-      original.conf && Object.keys(original.conf).length > 0 ? (
+      hasDagRunConfig(original.conf) ? (
         <RenderedJsonField collapsed={!open} content={original.conf} />
       ) : undefined,
     header: translate("dagRun.conf"),
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx
index 4151d543c1d..7a460996e98 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx
@@ -32,7 +32,7 @@ import { TeamName } from "src/components/TeamName";
 import Time from "src/components/Time";
 
 import { useShowTeam } from "src/hooks/useShowTeam";
-import { isStatePending, useAutoRefresh, useDurationFormat } from "src/utils";
+import { hasDagRunConfig, isStatePending, useAutoRefresh, useDurationFormat } 
from "src/utils";
 
 export const Details = () => {
   const { t: translate } = useTranslation(["common", "components"]);
@@ -196,7 +196,7 @@ export const Details = () => {
         <Table.Row>
           <Table.Cell>{translate("dagRun.conf")}</Table.Cell>
           <Table.Cell>
-            <RenderedJsonField content={dagRun.conf ?? {}} />
+            {hasDagRunConfig(dagRun.conf) ? <RenderedJsonField 
content={dagRun.conf} /> : undefined}
           </Table.Cell>
         </Table.Row>
       </Table.Body>
diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts 
b/airflow-core/src/airflow/ui/src/utils/hasDagRunConfig.test.ts
similarity index 55%
copy from airflow-core/src/airflow/ui/src/utils/index.ts
copy to airflow-core/src/airflow/ui/src/utils/hasDagRunConfig.test.ts
index 210e25586c8..a82548eb831 100644
--- a/airflow-core/src/airflow/ui/src/utils/index.ts
+++ b/airflow-core/src/airflow/ui/src/utils/hasDagRunConfig.test.ts
@@ -16,15 +16,18 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import { describe, it, expect } from "vitest";
 
-export { capitalize } from "./capitalize";
-export { createErrorToaster, getErrorStatus } from "./errorHandling";
-export { getMetaKey } from "./getMetaKey";
-export { toNullablePartitionKey } from "./partitionKey";
-export { useContainerWidth } from "./useContainerWidth";
-export { useDocumentTitle } from "./useDocumentTitle";
-export { type DurationFormat, useDurationFormat } from "./useDurationFormat";
-export { DocumentTitleProvider } from "./useDocumentTitleProvider";
-export { useFiltersHandler, type FilterableSearchParamsKeys } from 
"./useFiltersHandler";
-export * from "./query";
-export { STATE_PRIORITY, sortStateEntries } from "./stateUtils";
+import { hasDagRunConfig } from "./hasDagRunConfig";
+
+describe("hasDagRunConfig", () => {
+  it.each([
+    { description: "undefined", expected: false, input: undefined },
+    { description: "null", expected: false, input: null },
+    { description: "an empty object", expected: false, input: {} },
+    { description: "a non-empty object", expected: true, input: { country: 
"FR" } },
+    { description: "an object with several keys", expected: true, input: { 
batch: 42, env: "prod" } },
+  ])("returns $expected for $description", ({ expected, input }) => {
+    expect(hasDagRunConfig(input)).toBe(expected);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts 
b/airflow-core/src/airflow/ui/src/utils/hasDagRunConfig.ts
similarity index 55%
copy from airflow-core/src/airflow/ui/src/utils/index.ts
copy to airflow-core/src/airflow/ui/src/utils/hasDagRunConfig.ts
index 210e25586c8..d1740190fee 100644
--- a/airflow-core/src/airflow/ui/src/utils/index.ts
+++ b/airflow-core/src/airflow/ui/src/utils/hasDagRunConfig.ts
@@ -17,14 +17,8 @@
  * under the License.
  */
 
-export { capitalize } from "./capitalize";
-export { createErrorToaster, getErrorStatus } from "./errorHandling";
-export { getMetaKey } from "./getMetaKey";
-export { toNullablePartitionKey } from "./partitionKey";
-export { useContainerWidth } from "./useContainerWidth";
-export { useDocumentTitle } from "./useDocumentTitle";
-export { type DurationFormat, useDurationFormat } from "./useDurationFormat";
-export { DocumentTitleProvider } from "./useDocumentTitleProvider";
-export { useFiltersHandler, type FilterableSearchParamsKeys } from 
"./useFiltersHandler";
-export * from "./query";
-export { STATE_PRIORITY, sortStateEntries } from "./stateUtils";
+// A Dag run that was triggered without a config comes back from the API as an 
empty object `{}`,
+// not null/undefined, so a presence check has to treat `{}` as "no config 
supplied" too.
+export const hasDagRunConfig = (
+  conf: Record<string, unknown> | null | undefined,
+): conf is Record<string, unknown> => conf !== null && conf !== undefined && 
Object.keys(conf).length > 0;
diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts 
b/airflow-core/src/airflow/ui/src/utils/index.ts
index 210e25586c8..fc958e3ea37 100644
--- a/airflow-core/src/airflow/ui/src/utils/index.ts
+++ b/airflow-core/src/airflow/ui/src/utils/index.ts
@@ -20,6 +20,7 @@
 export { capitalize } from "./capitalize";
 export { createErrorToaster, getErrorStatus } from "./errorHandling";
 export { getMetaKey } from "./getMetaKey";
+export { hasDagRunConfig } from "./hasDagRunConfig";
 export { toNullablePartitionKey } from "./partitionKey";
 export { useContainerWidth } from "./useContainerWidth";
 export { useDocumentTitle } from "./useDocumentTitle";

Reply via email to