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 6fc06c60011 UI: Add Select Recent Configurations dropdown to Trigger 
Dag form (#70413)
6fc06c60011 is described below

commit 6fc06c600119c3948596ee1fd48c40cbb0325651
Author: Wilson Josix Wang <[email protected]>
AuthorDate: Wed Oct 14 04:14:44 2026 +0800

    UI: Add Select Recent Configurations dropdown to Trigger Dag form (#70413)
    
    * UI: Add Select Recent Configurations dropdown to Trigger Dag form
    
    Reusing a past run's config currently requires leaving the trigger flow
    to hunt for the right run in the run list and use "Trigger again with
    this config". Restoring the Airflow 2 in-form dropdown lets users pick
    any distinct config from the recent runs in a scrollable list while
    composing a run. The dropdown reuses the existing dagRuns list endpoint
    and the same conf-only prefill path as the menu item, so no new API
    surface is added.
    
    closes: #69820
    
    * UI: Regroup imports in RecentConfigSelect after rebase
    
    Main now separates React imports from third-party ones, and prettier
    flags the file otherwise.
    
    * UI: Keep user input when selecting a recent configuration
    
    Picking a recent configuration reused the re-trigger prefill path, which
    resets the whole form. A user who had already set a logical date, run id,
    note or manual data interval lost all of them. The dropdown is meant to
    swap only the run's conf, so it now leaves every other field untouched.
    
    * UI: Hide recent configurations when re-triggering with a run's config
    
    "Trigger again with this config" already fills the form with one specific
    run's conf, so showing an unselected recent-configurations dropdown on top
    of it is contradictory.
    
    * UI: Use system Select for the recent configurations dropdown
    
    The dropdown rendered inside the Trigger Dag modal was clipped at the
    modal edge because its content stayed inside the modal's DOM tree. The
    shared Select from src/system-components portals its content and already
    renders the control, indicator and positioner, so the hand-rolled Chakra
    parts only duplicated those layers.
    
    The trigger form tests also follow main, where the i18n mock returns
    keys and TriggerDAGForm no longer takes dagDisplayName.
---
 .../ui/public/i18n/locales/en/components.json      |   2 +
 .../TriggerDag/RecentConfigSelect.test.tsx         | 152 +++++++++++++++++++++
 .../components/TriggerDag/RecentConfigSelect.tsx   | 113 +++++++++++++++
 .../components/TriggerDag/TriggerDAGForm.test.tsx  | 119 +++++++++++++++-
 .../src/components/TriggerDag/TriggerDAGForm.tsx   |  62 +++++----
 5 files changed, 423 insertions(+), 25 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
index 19e3346f533..ab5b658685f 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
@@ -172,6 +172,8 @@
     "loadingFailed": "Failed to load Dag information. Please try again.",
     "manualRunDenied": "Manual runs are not allowed for this Dag",
     "partitionKeyHelp": "Optional - only applies to partitioned Dags",
+    "recentConfig": "Recent configurations",
+    "recentConfigPlaceholder": "Select a recent configuration",
     "runIdHelp": "Optional - will be generated if not provided",
     "selectDescription": "Trigger a single run of this Dag",
     "selectLabel": "Single Run",
diff --git 
a/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.test.tsx
 
b/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.test.tsx
new file mode 100644
index 00000000000..09a39585e49
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.test.tsx
@@ -0,0 +1,152 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import RecentConfigSelect from "./RecentConfigSelect";
+
+const buildRun = (overrides: Record<string, unknown>) => ({
+  bundle_version: null,
+  conf: null,
+  dag_display_name: "test_dag",
+  dag_id: "test_dag",
+  dag_run_id: "run_1",
+  dag_versions: [],
+  data_interval_end: null,
+  data_interval_start: null,
+  duration: null,
+  end_date: null,
+  last_scheduling_decision: null,
+  logical_date: null,
+  note: null,
+  partition_key: null,
+  queued_at: null,
+  run_after: "2025-01-01T00:00:00Z",
+  run_type: "manual" as const,
+  start_date: null,
+  state: "success" as const,
+  triggered_by: "ui" as const,
+  triggering_user_name: null,
+  ...overrides,
+});
+
+let mockData: { dag_runs: Array<ReturnType<typeof buildRun>> } | undefined;
+const mockIsLoading = false;
+
+vi.mock("openapi/queries", () => ({
+  useDagRunServiceGetDagRuns: vi.fn(() => ({
+    data: mockData,
+    isLoading: mockIsLoading,
+  })),
+}));
+
+// Select.Content is portalled to document.body, outside the render container.
+const getItems = () => document.body.querySelectorAll(".chakra-select__item");
+
+describe("RecentConfigSelect", () => {
+  it("renders one item per distinct non-empty conf", () => {
+    mockData = {
+      dag_runs: [
+        buildRun({ conf: { message: "First" }, dag_run_id: "run_1", run_after: 
"2025-01-03T00:00:00Z" }),
+        buildRun({ conf: { message: "Second" }, dag_run_id: "run_2", 
run_after: "2025-01-02T00:00:00Z" }),
+      ],
+    };
+
+    render(<RecentConfigSelect dagId="test_dag" onSelectConf={vi.fn()} open 
/>, { wrapper: Wrapper });
+
+    expect(getItems()).toHaveLength(2);
+  });
+
+  it("dedups identical confs", () => {
+    mockData = {
+      dag_runs: [
+        buildRun({ conf: { message: "Same" }, dag_run_id: "run_1", run_after: 
"2025-01-03T00:00:00Z" }),
+        buildRun({ conf: { message: "Same" }, dag_run_id: "run_2", run_after: 
"2025-01-02T00:00:00Z" }),
+      ],
+    };
+
+    render(<RecentConfigSelect dagId="test_dag" onSelectConf={vi.fn()} open 
/>, { wrapper: Wrapper });
+
+    expect(getItems()).toHaveLength(1);
+  });
+
+  it("excludes null/empty conf runs and renders nothing when none remain", () 
=> {
+    mockData = {
+      dag_runs: [buildRun({ conf: null, dag_run_id: "run_1" }), buildRun({ 
conf: {}, dag_run_id: "run_2" })],
+    };
+
+    const { container } = render(<RecentConfigSelect dagId="test_dag" 
onSelectConf={vi.fn()} open />, {
+      wrapper: Wrapper,
+    });
+
+    expect(container).toBeEmptyDOMElement();
+  });
+
+  it("renders all distinct confs without capping the list", () => {
+    mockData = {
+      dag_runs: Array.from({ length: 8 }, (_unused, index) =>
+        buildRun({
+          conf: { message: `Message ${index}` },
+          dag_run_id: `run_${index}`,
+          run_after: `2025-01-${(10 - index).toString().padStart(2, 
"0")}T00:00:00Z`,
+        }),
+      ),
+    };
+
+    render(<RecentConfigSelect dagId="test_dag" onSelectConf={vi.fn()} open 
/>, { wrapper: Wrapper });
+
+    expect(getItems()).toHaveLength(8);
+  });
+
+  it("calls onSelectConf with the selected run's conf", async () => {
+    mockData = {
+      dag_runs: [buildRun({ conf: { message: "Pick me" }, dag_run_id: "run_1" 
})],
+    };
+    const onSelectConf = vi.fn();
+
+    render(<RecentConfigSelect dagId="test_dag" onSelectConf={onSelectConf} 
open />, { wrapper: Wrapper });
+
+    fireEvent.click(screen.getByRole("combobox"));
+
+    await waitFor(() => 
expect(screen.getByRole("listbox")).toBeInTheDocument());
+
+    fireEvent.click(screen.getByText("run_1"));
+
+    await waitFor(() => expect(onSelectConf).toHaveBeenCalledWith({ message: 
"Pick me" }));
+  });
+
+  it("displays the selected run id in the trigger after selection", async () 
=> {
+    mockData = {
+      dag_runs: [buildRun({ conf: { message: "Pick me" }, dag_run_id: "run_1" 
})],
+    };
+
+    render(<RecentConfigSelect dagId="test_dag" onSelectConf={vi.fn()} open 
/>, { wrapper: Wrapper });
+
+    const trigger = screen.getByRole("combobox");
+
+    fireEvent.click(trigger);
+    await waitFor(() => 
expect(screen.getByRole("listbox")).toBeInTheDocument());
+    fireEvent.click(screen.getByText("run_1"));
+
+    await waitFor(() => expect(trigger).toHaveTextContent("run_1"));
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.tsx 
b/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.tsx
new file mode 100644
index 00000000000..8de65ed9a48
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.tsx
@@ -0,0 +1,113 @@
+/*!
+ * 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 { useMemo, useState } from "react";
+
+import { createListCollection, Flex, type SelectValueChangeDetails, Text } 
from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+
+import { useDagRunServiceGetDagRuns } from "openapi/queries";
+import type { DAGRunResponse } from "openapi/requests/types.gen";
+
+import { Select } from "src/system-components";
+
+import Time from "../Time";
+
+type RecentConfigOption = {
+  run: DAGRunResponse;
+  value: string;
+};
+
+type RecentConfigSelectProps = {
+  readonly dagId: string;
+  readonly onSelectConf: (conf: Record<string, unknown>) => void;
+  readonly open: boolean;
+};
+
+const RecentConfigSelect = ({ dagId, onSelectConf, open }: 
RecentConfigSelectProps) => {
+  const { t: translate } = useTranslation("components");
+  const [selectedValue, setSelectedValue] = useState<Array<string>>([]);
+  const { data, isLoading } = useDagRunServiceGetDagRuns(
+    { dagId, limit: 25, orderBy: ["-run_after"] },
+    undefined,
+    { enabled: open },
+  );
+
+  const options = useMemo(() => {
+    const seenConfs = new Set<string>();
+
+    return (data?.dag_runs ?? []).reduce<Array<RecentConfigOption>>((items, 
run) => {
+      const hasConf = run.conf !== null && Object.keys(run.conf).length > 0;
+      const confKey = hasConf ? JSON.stringify(run.conf) : undefined;
+      const isNewConf = confKey !== undefined && !seenConfs.has(confKey);
+
+      if (isNewConf) {
+        seenConfs.add(confKey);
+        items.push({ run, value: run.dag_run_id });
+      }
+
+      return items;
+    }, []);
+  }, [data?.dag_runs]);
+
+  const recentConfigOptions = createListCollection({
+    items: options,
+    itemToString: (item: RecentConfigOption) => item.run.dag_run_id,
+  });
+
+  const handleValueChange = ({ items, value }: 
SelectValueChangeDetails<RecentConfigOption>) => {
+    const [selected] = items;
+
+    setSelectedValue(value);
+    if (selected?.run.conf) {
+      onSelectConf(selected.run.conf);
+    }
+  };
+
+  if (!isLoading && options.length === 0) {
+    return undefined;
+  }
+
+  return (
+    <Select.Root
+      collection={recentConfigOptions}
+      data-testid="recent-config-select"
+      disabled={isLoading || options.length === 0}
+      onValueChange={handleValueChange}
+      size="sm"
+      value={selectedValue}
+    >
+      <Select.Label 
fontSize="xs">{translate("triggerDag.recentConfig")}</Select.Label>
+      <Select.Trigger>
+        <Select.ValueText 
placeholder={translate("triggerDag.recentConfigPlaceholder")} />
+      </Select.Trigger>
+      <Select.Content maxH="200px" overflowY="auto">
+        {recentConfigOptions.items.map((option) => (
+          <Select.Item item={option} key={option.run.dag_run_id}>
+            <Flex justifyContent="space-between" width="100%">
+              <Text>{option.run.dag_run_id}</Text>
+              <Time datetime={option.run.run_after} />
+            </Flex>
+          </Select.Item>
+        ))}
+      </Select.Content>
+    </Select.Root>
+  );
+};
+
+export default RecentConfigSelect;
diff --git 
a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.test.tsx 
b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.test.tsx
index 326fa529ca2..2d3cffef5bd 100644
--- 
a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.test.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.test.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 import "@testing-library/jest-dom/vitest";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor, within } from 
"@testing-library/react";
 import { beforeEach, describe, expect, it, vi } from "vitest";
 
 import type * as OpenapiQueries from "openapi/queries";
@@ -64,7 +64,21 @@ vi.mock("src/queries/useTogglePause", () => ({
 vi.mock("openapi/queries", async (importOriginal) => {
   const actual = await importOriginal<typeof OpenapiQueries>();
 
-  return { ...actual, useDagRunServiceGetDagRuns: vi.fn(() => ({ data: 
undefined })) };
+  return {
+    ...actual,
+    useDagRunServiceGetDagRuns: vi.fn(() => ({
+      data: {
+        dag_runs: [
+          {
+            conf: { message: "From recent" },
+            dag_run_id: "run_recent",
+            run_after: "2025-01-01T00:00:00Z",
+          },
+        ],
+      },
+      isLoading: false,
+    })),
+  };
 });
 
 vi.mock("../DateTimeInput", () => ({
@@ -273,4 +287,105 @@ describe("TriggerDAGForm", () => {
     await waitFor(() => 
expect(screen.getByText("dagRun.partitionKey")).toBeInTheDocument());
     
expect(screen.getByText("components:triggerDag.partitionKeyHelp")).toBeInTheDocument();
   });
+
+  it("prefills the form when a recent configuration is selected from the 
dropdown", async () => {
+    const { container } = render(
+      <TriggerDAGForm
+        dagId="example_params_trigger_ui"
+        error={undefined}
+        hasSchedule={false}
+        isPartitioned={false}
+        isPaused={false}
+        isPending={false}
+        onSubmitTrigger={vi.fn()}
+        open
+        prefillConfig={undefined}
+      />,
+      { wrapper: Wrapper },
+    );
+
+    const recentConfigSelect = screen.getByTestId("recent-config-select");
+
+    fireEvent.click(within(recentConfigSelect).getByRole("combobox"));
+    await waitFor(() => 
expect(screen.getByRole("listbox")).toBeInTheDocument());
+    fireEvent.click(screen.getByText("run_recent"));
+
+    await waitFor(() =>
+      
expect(container.querySelector<HTMLInputElement>('input[name="element_message"]')?.value).toBe(
+        "From recent",
+      ),
+    );
+
+    await waitFor(() => {
+      const configJson = screen.getByLabelText("Configuration JSON");
+
+      if (!(configJson instanceof HTMLTextAreaElement)) {
+        throw new TypeError("Expected Configuration JSON to render as a 
textarea");
+      }
+
+      expect(configJson.value).toContain('"From recent"');
+    });
+  });
+
+  it("keeps user-entered run id and note when a recent configuration is 
selected", async () => {
+    const { container } = render(
+      <TriggerDAGForm
+        dagId="example_params_trigger_ui"
+        error={undefined}
+        hasSchedule={false}
+        isPartitioned={false}
+        isPaused={false}
+        isPending={false}
+        onSubmitTrigger={vi.fn()}
+        open
+        prefillConfig={undefined}
+      />,
+      { wrapper: Wrapper },
+    );
+
+    const runIdInput = await screen.findByLabelText("runId");
+
+    fireEvent.change(runIdInput, { target: { value: "my_custom_run" } });
+
+    const recentConfigSelect = screen.getByTestId("recent-config-select");
+
+    fireEvent.click(within(recentConfigSelect).getByRole("combobox"));
+    await waitFor(() => 
expect(screen.getByRole("listbox")).toBeInTheDocument());
+    fireEvent.click(screen.getByText("run_recent"));
+
+    await waitFor(() =>
+      
expect(container.querySelector<HTMLInputElement>('input[name="element_message"]')?.value).toBe(
+        "From recent",
+      ),
+    );
+    expect(screen.getByLabelText("runId")).toHaveValue("my_custom_run");
+  });
+
+  it("hides the recent configuration dropdown when re-triggering with a prior 
run's config", async () => {
+    const { container } = render(
+      <TriggerDAGForm
+        dagId="example_params_trigger_ui"
+        error={undefined}
+        hasSchedule={false}
+        isPartitioned={false}
+        isPaused={false}
+        isPending={false}
+        onSubmitTrigger={vi.fn()}
+        open
+        prefillConfig={{
+          conf: { message: "Original message" },
+          logicalDate: undefined,
+          runId: "manual__test",
+        }}
+      />,
+      { wrapper: Wrapper },
+    );
+
+    await waitFor(() =>
+      
expect(container.querySelector<HTMLInputElement>('input[name="element_message"]')?.value).toBe(
+        "Original message",
+      ),
+    );
+    
expect(screen.queryByTestId("recent-config-select")).not.toBeInTheDocument();
+  });
 });
diff --git 
a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx 
b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx
index 959f908ccb5..e1a7487026e 100644
--- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
 
 import { Button, Box, Spacer, HStack, Field, Stack, Text, VStack } from 
"@chakra-ui/react";
 import dayjs from "dayjs";
@@ -35,6 +35,7 @@ import ConfigForm from "../ConfigForm";
 import { DateTimeInput } from "../DateTimeInput";
 import { ErrorAlert, type ExpandedApiError } from "../ErrorAlert";
 import PausedDagOptions from "./PausedDagOptions";
+import RecentConfigSelect from "./RecentConfigSelect";
 import TriggerDAGAdvancedOptions from "./TriggerDAGAdvancedOptions";
 import { dataIntervalModeOptions, type DagRunTriggerParams, type 
PausedDagAction } from "./types";
 
@@ -95,8 +96,38 @@ const TriggerDAGForm = ({
     },
   });
 
-  // Pre-fill form when prefillConfig is provided (priority over conf)
-  // Only restore 'conf' (parameters), not logicalDate, runId, or partitionKey 
to avoid 409 conflicts
+  // Push a conf into the param store. Seed the initial params only once they 
are available,
+  // but always push the conf so a run's configuration propagates even
+  // for Dags with no declared params or before params load.
+  const syncConfToStore = useCallback(
+    (confString: string) => {
+      if (
+        Object.keys(initialParamsDict.paramsDict).length > 0 &&
+        Object.keys(initialParamDict).length === 0
+      ) {
+        setInitialParamDict(initialParamsDict.paramsDict);
+      }
+      setConf(confString);
+    },
+    [initialParamDict, initialParamsDict.paramsDict, setConf, 
setInitialParamDict],
+  );
+
+  // Selecting a recent configuration mid-edit only swaps the conf; whatever 
the user already
+  // typed into logical date, run id, note or data interval is kept.
+  const applyRecentConf = useCallback(
+    (confObj: Record<string, unknown>) => {
+      const confString = JSON.stringify(confObj, undefined, 2);
+
+      reset((prevValues) => ({ ...prevValues, conf: confString }));
+      syncConfToStore(confString);
+    },
+    [reset, syncConfToStore],
+  );
+
+  // Pre-fill form when prefillConfig is provided. This runs on open, before 
any user input, so
+  // the whole form is reset. Only 'conf' is copied from the prior run; its 
runId and logicalDate
+  // are deliberately dropped, because POST /dagRuns rejects a run whose id or 
logical date
+  // already exists with 409 Conflict.
   useEffect(() => {
     if (prefillConfig && open) {
       const confString = prefillConfig.conf ? 
JSON.stringify(prefillConfig.conf, undefined, 2) : "";
@@ -111,32 +142,14 @@ const TriggerDAGForm = ({
         note: "",
         partitionKey: undefined,
       });
-      // Also update the param store to keep it in sync. Seed the initial 
params (for stable
-      // section ordering) only once they are available, but always push the 
conf so a run's
-      // configuration propagates even for Dags with no declared params or 
before params load.
       if (confString) {
-        if (
-          Object.keys(initialParamsDict.paramsDict).length > 0 &&
-          Object.keys(initialParamDict).length === 0
-        ) {
-          setInitialParamDict(initialParamsDict.paramsDict);
-        }
-        setConf(confString);
+        syncConfToStore(confString);
       }
       setHasAppliedPrefill(true);
     } else if (!open) {
       setHasAppliedPrefill(false);
     }
-  }, [
-    prefillConfig,
-    open,
-    reset,
-    setConf,
-    initialParamsDict.paramsDict,
-    initialParamDict,
-    setInitialParamDict,
-    isPartitioned,
-  ]);
+  }, [prefillConfig, open, isPartitioned, reset, syncConfToStore]);
 
   // Automatically reset form when conf is fetched (only if no prefillConfig)
   useEffect(() => {
@@ -253,6 +266,9 @@ const TriggerDAGForm = ({
             <Spacer />
           </>
         ) : undefined}
+        {prefillConfig ? undefined : (
+          <RecentConfigSelect dagId={dagId} onSelectConf={applyRecentConf} 
open={open} />
+        )}
         <ConfigForm
           control={control}
           errors={errors}

Reply via email to