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";