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 index 09a39585e4993..8bf0d9004f9c3 100644 --- a/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.test.tsx +++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.test.tsx @@ -16,9 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -import "@testing-library/jest-dom"; +import "@testing-library/jest-dom/vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { Wrapper } from "src/utils/Wrapper"; @@ -50,15 +50,22 @@ const buildRun = (overrides: Record) => ({ }); let mockData: { dag_runs: Array> } | undefined; -const mockIsLoading = false; +// Served from cache on a reopen: data is present without any fetch having happened since mount. +let mockFetchedAfterMount = true; vi.mock("openapi/queries", () => ({ useDagRunServiceGetDagRuns: vi.fn(() => ({ data: mockData, - isLoading: mockIsLoading, + isFetchedAfterMount: mockFetchedAfterMount, + isFetching: false, + isSuccess: mockData !== undefined, })), })); +beforeEach(() => { + mockFetchedAfterMount = true; +}); + // Select.Content is portalled to document.body, outside the render container. const getItems = () => document.body.querySelectorAll(".chakra-select__item"); @@ -101,6 +108,30 @@ describe("RecentConfigSelect", () => { expect(container).toBeEmptyDOMElement(); }); + it("renders nothing while the runs are still loading", () => { + mockFetchedAfterMount = false; + mockData = undefined; + + const { container } = render(, { + wrapper: Wrapper, + }); + + expect(container).toBeEmptyDOMElement(); + }); + + // Runs stay fresh for five minutes, so reopening the modal inside that window serves them from + // cache and nothing is fetched after this mount. The configs are still there to offer. + it("still offers configs when the runs came from cache", () => { + mockFetchedAfterMount = false; + mockData = { + dag_runs: [buildRun({ conf: { message: "From cache" }, dag_run_id: "run_cached" })], + }; + + render(, { wrapper: Wrapper }); + + expect(getItems()).toHaveLength(1); + }); + it("renders all distinct confs without capping the list", () => { mockData = { dag_runs: Array.from({ length: 8 }, (_unused, index) => diff --git a/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.tsx b/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.tsx index 8de65ed9a48e8..d689da850dacd 100644 --- a/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.tsx +++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/RecentConfigSelect.tsx @@ -18,7 +18,14 @@ */ import { useMemo, useState } from "react"; -import { createListCollection, Flex, type SelectValueChangeDetails, Text } from "@chakra-ui/react"; +import { + createListCollection, + Field, + Flex, + type SelectValueChangeDetails, + Stack, + Text, +} from "@chakra-ui/react"; import { useTranslation } from "react-i18next"; import { useDagRunServiceGetDagRuns } from "openapi/queries"; @@ -42,11 +49,9 @@ type RecentConfigSelectProps = { 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 { data } = useDagRunServiceGetDagRuns({ dagId, limit: 25, orderBy: ["-run_after"] }, undefined, { + enabled: open, + }); const options = useMemo(() => { const seenConfs = new Set(); @@ -79,34 +84,48 @@ const RecentConfigSelect = ({ dagId, onSelectConf, open }: RecentConfigSelectPro } }; - if (!isLoading && options.length === 0) { + // The options come from the runs alone, so there is nothing to show both before they arrive + // and when none of them carried a config. Asking the query how far along it is would be worse + // than useless here: the runs stay fresh for five minutes, so reopening the modal serves them + // from cache without fetching, and a readiness flag would read that as "not ready yet" and + // hide configs that are already in hand. + if (options.length === 0) { return undefined; } + // Laid out as a form row like Logical Date and the run parameters below it, rather than a + // label stacked over a full-width control. return ( - - {translate("triggerDag.recentConfig")} - - - - - {recentConfigOptions.items.map((option) => ( - - - {option.run.dag_run_id} - - - ))} - - + + + + {translate("triggerDag.recentConfig")} + + + + + + + + + {recentConfigOptions.items.map((option) => ( + + + {option.run.dag_run_id} + + + ))} + + + + ); }; 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 d4a4e96bdb4a7..44040b248f878 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 @@ -79,7 +79,9 @@ vi.mock("openapi/queries", async (importOriginal) => { }, ], }, - isLoading: false, + isFetchedAfterMount: true, + isFetching: false, + isSuccess: true, })), }; });