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 837726e376dc6..b4c71b2764550 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 @@ -138,6 +138,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 0000000000000..5bcf69bb3b1cf --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.test.tsx @@ -0,0 +1,157 @@ +/*! + * 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) => ({ + 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> } | undefined; +const mockIsLoading = false; + +vi.mock("openapi/queries", () => ({ + useDagRunServiceGetDagRuns: vi.fn(() => ({ + data: mockData, + isLoading: mockIsLoading, + })), +})); + +const getItems = (container: HTMLElement) => container.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" }), + ], + }; + + const { container } = render(, { + wrapper: Wrapper, + }); + + expect(getItems(container)).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" }), + ], + }; + + const { container } = render(, { + wrapper: Wrapper, + }); + + expect(getItems(container)).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(, { + 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`, + }), + ), + }; + + const { container } = render(, { + wrapper: Wrapper, + }); + + expect(getItems(container)).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(, { 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(, { 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 0000000000000..19873dce8d096 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.tsx @@ -0,0 +1,117 @@ +/*! + * 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 { createListCollection, Flex, Select, type SelectValueChangeDetails, Text } from "@chakra-ui/react"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useDagRunServiceGetDagRuns } from "openapi/queries"; +import type { DAGRunResponse } from "openapi/requests/types.gen"; + +import Time from "../Time"; + +type RecentConfigOption = { + run: DAGRunResponse; + value: string; +}; + +type RecentConfigSelectProps = { + readonly dagId: string; + readonly onSelectConf: (conf: Record) => void; + readonly open: boolean; +}; + +const RecentConfigSelect = ({ dagId, onSelectConf, open }: RecentConfigSelectProps) => { + const { t: translate } = useTranslation("components"); + const [selectedValue, setSelectedValue] = useState>([]); + const { data, isLoading } = useDagRunServiceGetDagRuns( + { dagId, limit: 25, orderBy: ["-run_after"] }, + undefined, + { enabled: open }, + ); + + const options = useMemo(() => { + const seenConfs = new Set(); + + return (data?.dag_runs ?? []).reduce>((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) => { + const [selected] = items; + + setSelectedValue(value); + if (selected?.run.conf) { + onSelectConf(selected.run.conf); + } + }; + + if (!isLoading && options.length === 0) { + return undefined; + } + + return ( + + {translate("triggerDag.recentConfig")} + + + + + + + + + + + {recentConfigOptions.items.map((option) => ( + + + {option.run.dag_run_id} + + + ))} + + + + ); +}; + +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 2181842f68154..acf8eb4b5ae95 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"; -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 { Wrapper } from "src/utils/Wrapper"; @@ -53,6 +53,21 @@ vi.mock("src/queries/useDagParams", () => ({ useDagParams: useDagParamsMock, })); +vi.mock("openapi/queries", () => ({ + 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("src/queries/useTogglePause", () => ({ useTogglePause: () => ({ mutate: vi.fn(), @@ -214,4 +229,46 @@ 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( + , + { 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('input[name="element_message"]')?.value).toBe( + "From recent", + ), + ); + + fireEvent.click(screen.getByText("Advanced Options")); + + 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"'); + }); + }); }); 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 185c3da46f90b..e94fc3453888f 100644 --- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx +++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx @@ -18,7 +18,7 @@ */ import { Button, Box, Spacer, HStack, Field, Stack, Text, VStack } from "@chakra-ui/react"; import dayjs from "dayjs"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { FiPlay } from "react-icons/fi"; @@ -33,6 +33,7 @@ import { DateTimeInput } from "../DateTimeInput"; import { ErrorAlert, type ExpandedApiError } from "../ErrorAlert"; import { Checkbox } from "../ui/Checkbox"; import { RadioCardItem, RadioCardRoot } from "../ui/RadioCard"; +import RecentConfigSelect from "./RecentConfigSelect"; import TriggerDAGAdvancedOptions from "./TriggerDAGAdvancedOptions"; import { dataIntervalModeOptions, type DagRunTriggerParams } from "./types"; @@ -91,11 +92,12 @@ 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 - useEffect(() => { - if (prefillConfig && open) { - const confString = prefillConfig.conf ? JSON.stringify(prefillConfig.conf, undefined, 2) : ""; + // Apply a config to the form and param store, resetting the other fields to their defaults. + // Only 'conf' (parameters) is ever restored, never logicalDate, runId, or partitionKey, to avoid 409 conflicts. + // Shared by the prefill effect below (re-trigger with a prior run's config) and RecentConfigSelect. + const applyConf = useCallback( + (confObj: Record | undefined) => { + const confString = confObj ? JSON.stringify(confObj, undefined, 2) : ""; reset({ conf: confString, @@ -119,20 +121,19 @@ const TriggerDAGForm = ({ } setConf(confString); } + }, + [initialParamDict, initialParamsDict.paramsDict, isPartitioned, reset, setConf, setInitialParamDict], + ); + + // Pre-fill form when prefillConfig is provided (priority over conf) + useEffect(() => { + if (prefillConfig && open) { + applyConf(prefillConfig.conf); setHasAppliedPrefill(true); } else if (!open) { setHasAppliedPrefill(false); } - }, [ - prefillConfig, - open, - reset, - setConf, - initialParamsDict.paramsDict, - initialParamDict, - setInitialParamDict, - isPartitioned, - ]); + }, [prefillConfig, open, applyConf]); // Automatically reset form when conf is fetched (only if no prefillConfig) useEffect(() => { @@ -241,6 +242,7 @@ const TriggerDAGForm = ({ ) : undefined} +