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 c081c558f1c UI: Show partition preview in backfill form for
partitioned Dags (#68884)
c081c558f1c is described below
commit c081c558f1c1edc9771d9d136995764d24251834
Author: Wei Lee <[email protected]>
AuthorDate: Mon Aug 24 22:36:59 2026 +0900
UI: Show partition preview in backfill form for partitioned Dags (#68884)
The backfill form was purely date-range oriented and the dry-run preview
showed only a count, so users backfilling a partitioned Dag could not see
which partitions the run would actually cover. The form now switches to
partition wording for partitioned Dags and the preview lists the affected
partition keys one per line (capped, with an "and N more" summary), falling
back to the existing count message for non-partitioned Dags. Partition keys
are long enough that a comma-separated single line is unreadable, so the
list is laid out vertically. An empty range shows a "no partitions" message
and keeps the Run button disabled, matching the backend's friendly
empty-window behaviour.
---
.../airflow/ui/public/i18n/locales/en/common.json | 2 +
.../ui/public/i18n/locales/en/components.json | 4 +
.../ui/public/i18n/locales/zh-TW/common.json | 2 +
.../ui/public/i18n/locales/zh-TW/components.json | 4 +
.../DagActions/PartitionPreviewTable.tsx | 67 ++++++
.../components/DagActions/RunBackfillForm.test.tsx | 213 +++++++++++++++++++
.../src/components/DagActions/RunBackfillForm.tsx | 11 +-
.../components/DagActions/inlineMessage.test.tsx | 235 +++++++++++++++++++++
.../ui/src/components/DagActions/inlineMessage.tsx | 67 +++++-
9 files changed, 594 insertions(+), 11 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 399ef23c5e7..34bb560eb52 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -197,6 +197,8 @@
"write": "Write"
},
"overallStatus": "Overall Status",
+ "partition_one": "Partition",
+ "partition_other": "Partitions",
"partitionedDagRun_one": "Partitioned Dag Run",
"partitionedDagRun_other": "Partitioned Dag Runs",
"partitionedDagRunDetail": {
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 4da7700ed14..17443023152 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
@@ -17,6 +17,10 @@
"missingRuns": "Missing Runs",
"notCreatedReason": "Not Created Reason",
"overrideExistingParams": "Override parameters on existing runs",
+ "partitionRange": "Partition Range",
+ "partitionsAffected_one": "1 partition will be backfilled:",
+ "partitionsAffected_other": "{{count}} partitions will be backfilled:",
+ "partitionsNone": "No partitions matching selected range.",
"permissionDenied": "Dry Run Failed: User does not have permission to
create backfills.",
"reprocessBehavior": "Reprocess Behavior",
"run": "Run Backfill",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json
index 961fb522373..956f8aea2a4 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json
@@ -189,6 +189,8 @@
"write": "撰寫"
},
"overallStatus": "整體狀態",
+ "partition_one": "資源分區",
+ "partition_other": "資源分區",
"partitionedDagRun_one": "分區的 Dag 執行",
"partitionedDagRun_other": "分區的 Dag 執行",
"partitionedDagRunDetail": {
diff --git
a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/components.json
b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/components.json
index 54488c93727..18ad0815f26 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/components.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/components.json
@@ -17,6 +17,10 @@
"missingRuns": "遺漏的執行",
"notCreatedReason": "未建立原因",
"overrideExistingParams": "覆寫既有執行的參數",
+ "partitionRange": "分區範圍",
+ "partitionsAffected_one": "將會回填 1 個分區:",
+ "partitionsAffected_other": "將會回填 {{count}} 個分區:",
+ "partitionsNone": "無符合所選範圍的分區。",
"permissionDenied": "試執行失敗:使用者沒有建立回填的權限。",
"reprocessBehavior": "重新處理行為",
"run": "執行回填",
diff --git
a/airflow-core/src/airflow/ui/src/components/DagActions/PartitionPreviewTable.tsx
b/airflow-core/src/airflow/ui/src/components/DagActions/PartitionPreviewTable.tsx
new file mode 100644
index 00000000000..e7537ddbc31
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/DagActions/PartitionPreviewTable.tsx
@@ -0,0 +1,67 @@
+/*!
+ * 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 { Text } from "@chakra-ui/react";
+import type { ColumnDef } from "@tanstack/react-table";
+import type { TFunction } from "i18next";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import type { DryRunBackfillResponse } from "openapi/requests/types.gen";
+import { DataTable } from "src/components/DataTable";
+
+const pageSize = 10;
+
+type PartitionRow = { partition_key: string } & DryRunBackfillResponse;
+
+type PartitionPreviewTableProps = {
+ readonly backfills: Array<PartitionRow>;
+};
+
+const getColumns = (translate: TFunction): Array<ColumnDef<PartitionRow>> => [
+ {
+ accessorKey: "partition_key",
+ cell: ({ row }) =>
+ row.original.partition_key === "" ? (
+ <Text color="fg.muted">—</Text>
+ ) : (
+ <Text>{row.original.partition_key}</Text>
+ ),
+ enableSorting: false,
+ header: translate("dagRun.partitionKey"),
+ },
+];
+
+export const PartitionPreviewTable = ({ backfills }:
PartitionPreviewTableProps) => {
+ const { t: translate } = useTranslation(["common"]);
+ const [pageIndex, setPageIndex] = useState(0);
+
+ const columns = getColumns(translate);
+
+ return (
+ <DataTable
+ columns={columns}
+ data={backfills.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize)}
+ hideRowCountHeading
+ initialState={{ pagination: { pageIndex, pageSize }, sorting: [] }}
+ modelName="partition"
+ onStateChange={(state) => setPageIndex(state.pagination.pageIndex)}
+ total={backfills.length}
+ />
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.test.tsx
b/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.test.tsx
new file mode 100644
index 00000000000..5ed9f4218ae
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.test.tsx
@@ -0,0 +1,213 @@
+/*!
+ * 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 } from "@testing-library/react";
+import type { ChangeEventHandler } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import RunBackfillForm from "./RunBackfillForm";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ // eslint-disable-next-line id-length
+ t: (key: string, opts?: { count?: number; dag_display_name?: string }) => {
+ const map: Record<string, string> = {
+ "backfill.affected": `${String(opts?.count)} runs will be triggered.`,
+ "backfill.affectedNone": "No runs matching selected criteria.",
+ "backfill.backwards": "Run Backwards",
+ "backfill.dateRange": "Date Range",
+ "backfill.maxRuns": "Max Active Runs",
+ "backfill.overrideExistingParams": "Override parameters on existing
runs",
+ "backfill.partitionRange": "Partition Range",
+ "backfill.partitionsAffected": `${String(opts?.count)} partitions will
be backfilled:`,
+ "backfill.partitionsNone": "No partitions matching selected range.",
+ "backfill.reprocessBehavior": "Reprocess Behavior",
+ "backfill.run": "Run Backfill",
+ "backfill.schedulerPriorityHint": "Scheduler priority hint",
+ "common:modal.cancel": "Cancel",
+ "common:table.from": "From",
+ "common:table.to": "To",
+ "dags:runAndTaskActions.options.runOnLatestVersion": "Run on latest
version",
+ };
+
+ return map[key] ?? key;
+ },
+ }),
+}));
+
+vi.mock("openapi/queries", () => ({
+ useDagServiceGetDagDetails: vi.fn(() => ({ data: undefined })),
+}));
+
+vi.mock("src/queries/useCreateBackfillDryRun", () => ({
+ useCreateBackfillDryRun: vi.fn(() => ({
+ data: undefined,
+ error: undefined,
+ isPending: false,
+ })),
+}));
+
+vi.mock("src/queries/useCreateBackfill", () => ({
+ useCreateBackfill: vi.fn(() => ({
+ createBackfill: vi.fn(),
+ dateValidationError: undefined,
+ error: undefined,
+ isPending: false,
+ })),
+}));
+
+vi.mock("src/queries/useDagParams", () => ({
+ useDagParams: vi.fn(() => ({ paramsDict: {} })),
+}));
+
+vi.mock("src/queries/useParamStore", () => ({
+ useParamStore: vi.fn(() => ({ conf: "{}" })),
+}));
+
+vi.mock("src/queries/useTogglePause", () => ({
+ useTogglePause: vi.fn(() => ({ mutate: vi.fn() })),
+}));
+
+vi.mock("src/components/Clear/useRerunWithLatestVersion", () => ({
+ useRerunWithLatestVersion: vi.fn(() => ({ value: false })),
+}));
+
+vi.mock("../DateTimeInput", () => ({
+ DateTimeInput: ({
+ onChange,
+ value = "",
+ }: {
+ readonly onChange?: ChangeEventHandler<HTMLInputElement>;
+ readonly value?: string;
+ }) => <input aria-label="datetime" onChange={onChange} value={value} />,
+}));
+
+vi.mock("../ConfigForm", () => ({
+ default: () => <div data-testid="config-form" />,
+}));
+
+const baseDag = {
+ dag_display_name: "Test Dag",
+ dag_id: "test_dag",
+ is_paused: false,
+ max_active_runs: 10,
+ timetable_partitioned: false,
+};
+
+const { useDagServiceGetDagDetails } = await import("openapi/queries");
+const { useCreateBackfillDryRun } = await
import("src/queries/useCreateBackfillDryRun");
+
+describe("RunBackfillForm", () => {
+ it("shows 'Date Range' label for non-partitioned Dags", () => {
+ vi.mocked(useDagServiceGetDagDetails).mockReturnValue({
+ data: { ...baseDag, timetable_partitioned: false },
+ } as ReturnType<typeof useDagServiceGetDagDetails>);
+
+ render(<RunBackfillForm dag={baseDag as never} onClose={vi.fn()} />, {
wrapper: Wrapper });
+
+ expect(screen.getByText("Date Range")).toBeInTheDocument();
+ expect(screen.queryByText("Partition Range")).not.toBeInTheDocument();
+ });
+
+ it("shows 'Partition Range' label for partitioned Dags", () => {
+ vi.mocked(useDagServiceGetDagDetails).mockReturnValue({
+ data: { ...baseDag, timetable_partitioned: true },
+ } as ReturnType<typeof useDagServiceGetDagDetails>);
+
+ render(<RunBackfillForm dag={{ ...baseDag, timetable_partitioned: true }
as never} onClose={vi.fn()} />, {
+ wrapper: Wrapper,
+ });
+
+ expect(screen.getByText("Partition Range")).toBeInTheDocument();
+ expect(screen.queryByText("Date Range")).not.toBeInTheDocument();
+ });
+
+ it("disables Run button when dry-run returns 0 entries", () => {
+ vi.mocked(useDagServiceGetDagDetails).mockReturnValue({
+ data: { ...baseDag, timetable_partitioned: true },
+ } as ReturnType<typeof useDagServiceGetDagDetails>);
+
+ vi.mocked(useCreateBackfillDryRun).mockReturnValue({
+ data: { backfills: [], total_entries: 0 },
+ error: undefined,
+ isPending: false,
+ } as ReturnType<typeof useCreateBackfillDryRun>);
+
+ render(<RunBackfillForm dag={{ ...baseDag, timetable_partitioned: true }
as never} onClose={vi.fn()} />, {
+ wrapper: Wrapper,
+ });
+
+ expect(screen.getByText("Run Backfill")).toBeDisabled();
+ });
+
+ it("does not disable Run button when dry-run returns entries and dates are
filled", () => {
+ vi.mocked(useDagServiceGetDagDetails).mockReturnValue({
+ data: { ...baseDag, timetable_partitioned: false },
+ } as ReturnType<typeof useDagServiceGetDagDetails>);
+
+ vi.mocked(useCreateBackfillDryRun).mockReturnValue({
+ data: {
+ backfills: [{ logical_date: "2024-01-01T00:00:00Z", partition_date:
null, partition_key: null }],
+ total_entries: 1,
+ },
+ error: undefined,
+ isPending: false,
+ } as ReturnType<typeof useCreateBackfillDryRun>);
+
+ render(<RunBackfillForm dag={baseDag as never} onClose={vi.fn()} />, {
wrapper: Wrapper });
+
+ expect(screen.getByText("Run Backfill")).not.toBeDisabled();
+ });
+
+ it("renders partition key list when dates are filled and dry-run returns
partitioned data", () => {
+ vi.mocked(useDagServiceGetDagDetails).mockReturnValue({
+ data: { ...baseDag, timetable_partitioned: true },
+ } as ReturnType<typeof useDagServiceGetDagDetails>);
+
+ vi.mocked(useCreateBackfillDryRun).mockReturnValue({
+ data: {
+ backfills: [
+ { logical_date: null, partition_date: null, partition_key:
"2024-01-01T00/2024-01-02T00" },
+ { logical_date: null, partition_date: null, partition_key:
"2024-01-02T00/2024-01-03T00" },
+ ],
+ total_entries: 2,
+ },
+ error: undefined,
+ isPending: false,
+ } as ReturnType<typeof useCreateBackfillDryRun>);
+
+ render(<RunBackfillForm dag={{ ...baseDag, timetable_partitioned: true }
as never} onClose={vi.fn()} />, {
+ wrapper: Wrapper,
+ });
+
+ const [fromInput, toInput] = screen.getAllByLabelText("datetime");
+
+ fireEvent.change(fromInput as HTMLElement, { target: { value:
"2024-01-01T00:00" } });
+ fireEvent.change(toInput as HTMLElement, { target: { value:
"2024-01-03T00:00" } });
+
+ expect(screen.getByText("2 partitions will be
backfilled:")).toBeInTheDocument();
+
expect(screen.getByText("2024-01-01T00/2024-01-02T00")).toBeInTheDocument();
+
expect(screen.getByText("2024-01-02T00/2024-01-03T00")).toBeInTheDocument();
+ // Partition keys render one per table row rather than joined inline,
since keys can be long.
+ expect(screen.getAllByTestId("table-cell-partition_key")).toHaveLength(2);
+ expect(screen.queryByTestId("next")).not.toBeInTheDocument();
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
b/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
index de857da0f94..c3479d45925 100644
--- a/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
@@ -139,12 +139,19 @@ const RunBackfillForm = ({ dag, onClose }:
RunBackfillFormProps) => {
const resetDateError = () => setErrors((prev) => ({ ...prev, date: undefined
}));
const affectedTasks = data ?? { backfills: [], total_entries: 0 };
+ const isPartitioned = dag.timetable_partitioned;
// Check if the dry run error is a permission error (403)
const isPermissionError =
dryRunError !== undefined && dryRunError !== null && (dryRunError as
ExpandedApiError).status === 403;
- const inlineMessage = getInlineMessage(isPendingDryRun,
affectedTasks.total_entries, translate);
+ const inlineMessage = getInlineMessage({
+ backfills: affectedTasks.backfills,
+ isPartitioned,
+ isPendingDryRun,
+ totalEntries: affectedTasks.total_entries,
+ translate,
+ });
return (
<>
@@ -157,7 +164,7 @@ const RunBackfillForm = ({ dag, onClose }:
RunBackfillFormProps) => {
<Alert
status="info">{translate("backfill.schedulerPriorityHint")}</Alert>
<Box>
<Text fontSize="md" fontWeight="semibold" mb={3}>
- {translate("backfill.dateRange")}
+ {isPartitioned ? translate("backfill.partitionRange") :
translate("backfill.dateRange")}
</Text>
<HStack alignItems="flex-start" w="full">
<Controller
diff --git
a/airflow-core/src/airflow/ui/src/components/DagActions/inlineMessage.test.tsx
b/airflow-core/src/airflow/ui/src/components/DagActions/inlineMessage.test.tsx
new file mode 100644
index 00000000000..32a3220ffbf
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/DagActions/inlineMessage.test.tsx
@@ -0,0 +1,235 @@
+/*!
+ * 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 type { TFunction } from "i18next";
+import type { ReactNode } from "react";
+import { describe, expect, it } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { getInlineMessage } from "./inlineMessage";
+
+const translate = ((key: string, opts?: { count?: number }) => {
+ const map: Record<string, string> = {
+ "backfill.affected": `${String(opts?.count)} runs will be triggered.`,
+ "backfill.affectedNone": "No runs matching selected criteria.",
+ "backfill.partitionsAffected": `${String(opts?.count)} partitions will be
backfilled:`,
+ "backfill.partitionsNone": "No partitions matching selected range.",
+ };
+
+ return map[key] ?? key;
+}) as TFunction;
+
+const makeBackfills = (partitionKeys: Array<string | null>) =>
+ partitionKeys.map((partitionKey) => ({
+ logical_date: null,
+ partition_date: null,
+ partition_key: partitionKey,
+ }));
+
+const renderMessage = (el: ReactNode) => render(el, { wrapper: Wrapper });
+
+const makeHourlyKeys = (count: number) =>
+ Array.from({ length: count }, (_, idx) => `2024-01-${String(idx +
1).padStart(2, "0")}T00`);
+
+// The DataTable re-slices data asynchronously after a page change, so
`table-cell-partition_key`
+// matches non-empty before and after the click -- `findBy*` would resolve on
the stale render.
+// Poll until the row count settles on the expected page instead.
+const assertPartitionKeyCellCount = (length: number) =>
+ waitFor(() =>
expect(screen.getAllByTestId("table-cell-partition_key")).toHaveLength(length));
+
+describe("getInlineMessage", () => {
+ describe("non-partitioned", () => {
+ it("shows skeleton while pending", () => {
+ const el = getInlineMessage({ isPendingDryRun: true, totalEntries: 0,
translate });
+ const { container } = renderMessage(el);
+
+ expect(container.querySelector(".chakra-skeleton")).toBeInTheDocument();
+ });
+
+ it("shows error text when totalEntries is 0", () => {
+ const el = getInlineMessage({ isPendingDryRun: false, totalEntries: 0,
translate });
+
+ renderMessage(el);
+ expect(screen.getByText("No runs matching selected
criteria.")).toBeInTheDocument();
+ });
+
+ it("shows count message when totalEntries > 0", () => {
+ const el = getInlineMessage({ isPendingDryRun: false, totalEntries: 3,
translate });
+
+ renderMessage(el);
+ expect(screen.getByText("3 runs will be
triggered.")).toBeInTheDocument();
+ });
+ });
+
+ describe("partitioned", () => {
+ it("shows 'no partitions' message when totalEntries is 0", () => {
+ const el = getInlineMessage({
+ isPartitioned: true,
+ isPendingDryRun: false,
+ totalEntries: 0,
+ translate,
+ });
+
+ renderMessage(el);
+ expect(screen.getByText("No partitions matching selected
range.")).toBeInTheDocument();
+ });
+
+ it("lists partition_key values when totalEntries > 0", () => {
+ const backfills = makeBackfills(["2024-01-01T00/2024-01-01T01",
"2024-01-01T01/2024-01-01T02"]);
+ const el = getInlineMessage({
+ backfills,
+ isPartitioned: true,
+ isPendingDryRun: false,
+ totalEntries: 2,
+ translate,
+ });
+
+ renderMessage(el);
+ expect(screen.getByText("2 partitions will be
backfilled:")).toBeInTheDocument();
+
expect(screen.getByText("2024-01-01T00/2024-01-01T01")).toBeInTheDocument();
+
expect(screen.getByText("2024-01-01T01/2024-01-01T02")).toBeInTheDocument();
+ // Partition keys render one per row in a table rather than joined
inline.
+
expect(screen.getAllByTestId("table-cell-partition_key")).toHaveLength(2);
+ expect(screen.queryByTestId("next")).not.toBeInTheDocument();
+ });
+
+ it("skips null partition_key entries", () => {
+ const backfills = makeBackfills([null, "2024-01-01T00/2024-01-01T01"]);
+ const el = getInlineMessage({
+ backfills,
+ isPartitioned: true,
+ isPendingDryRun: false,
+ totalEntries: 2,
+ translate,
+ });
+
+ renderMessage(el);
+ expect(screen.queryByText("null")).not.toBeInTheDocument();
+
expect(screen.getByText("2024-01-01T00/2024-01-01T01")).toBeInTheDocument();
+
expect(screen.getAllByTestId("table-cell-partition_key")).toHaveLength(1);
+ expect(screen.queryByTestId("next")).not.toBeInTheDocument();
+ });
+
+ it("shows pagination controls when entries exceed the page size", () => {
+ const keys = makeHourlyKeys(12);
+ const backfills = makeBackfills(keys);
+ const el = getInlineMessage({
+ backfills,
+ isPartitioned: true,
+ isPendingDryRun: false,
+ totalEntries: 12,
+ translate,
+ });
+
+ renderMessage(el);
+ expect(screen.getByText("12 partitions will be
backfilled:")).toBeInTheDocument();
+ // Only the first page (page size 10) renders; the rest are reachable
via the next page.
+
expect(screen.getAllByTestId("table-cell-partition_key")).toHaveLength(10);
+ expect(screen.getByTestId("next")).toBeInTheDocument();
+ });
+
+ it("shows pagination controls when entries are exactly one over the page
size", async () => {
+ // 11 keys: 10 shown on the first page, 1 reachable via the next page.
Even a single
+ // remaining key must trigger pagination -- unlike the old popover
affordance, which
+ // rendered a lone remainder inline without one.
+ const keys = makeHourlyKeys(11);
+ const backfills = makeBackfills(keys);
+ const el = getInlineMessage({
+ backfills,
+ isPartitioned: true,
+ isPendingDryRun: false,
+ totalEntries: 11,
+ translate,
+ });
+
+ renderMessage(el);
+
expect(screen.getAllByTestId("table-cell-partition_key")).toHaveLength(10);
+ expect(screen.getByTestId("next")).toBeInTheDocument();
+
+ const remainingKeys = keys.slice(10);
+
+ fireEvent.click(screen.getByTestId("next"));
+ await assertPartitionKeyCellCount(remainingKeys.length);
+ for (const key of remainingKeys) {
+ expect(screen.getByText(key)).toBeInTheDocument();
+ }
+ });
+
+ it("does not show pagination controls when entries are within the page
size", () => {
+ const keys = makeHourlyKeys(5);
+ const backfills = makeBackfills(keys);
+ const el = getInlineMessage({
+ backfills,
+ isPartitioned: true,
+ isPendingDryRun: false,
+ totalEntries: 5,
+ translate,
+ });
+
+ renderMessage(el);
+ expect(screen.queryByTestId("next")).not.toBeInTheDocument();
+ for (const key of keys) {
+ expect(screen.getByText(key)).toBeInTheDocument();
+ }
+ });
+
+ it("does not show pagination controls when entries exactly equal the page
size", () => {
+ // Mirrors the pageSize (10) constant used by PartitionPreviewTable.
+ const pageSize = 10;
+ const keys = makeHourlyKeys(pageSize);
+ const backfills = makeBackfills(keys);
+ const el = getInlineMessage({
+ backfills,
+ isPartitioned: true,
+ isPendingDryRun: false,
+ totalEntries: pageSize,
+ translate,
+ });
+
+ renderMessage(el);
+
+ for (const key of keys) {
+ expect(screen.getByText(key)).toBeInTheDocument();
+ }
+ expect(screen.queryByTestId("next")).not.toBeInTheDocument();
+ });
+
+ it("bases pagination on non-null keys, not totalEntries, when nulls are
mixed with overflow", async () => {
+ const nullEntries: Array<string | null> = [null, null, null];
+ const keys = makeHourlyKeys(12);
+ const backfills = makeBackfills([...nullEntries, ...keys]);
+ const el = getInlineMessage({
+ backfills,
+ isPartitioned: true,
+ isPendingDryRun: false,
+ totalEntries: 15,
+ translate,
+ });
+
+ renderMessage(el);
+ expect(screen.getByText("15 partitions will be
backfilled:")).toBeInTheDocument();
+ // 12 non-null keys, page size 10 -> next page holds 2, regardless of
totalEntries (15).
+ expect(screen.getByTestId("next")).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId("next"));
+ await assertPartitionKeyCellCount(2);
+ });
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/DagActions/inlineMessage.tsx
b/airflow-core/src/airflow/ui/src/components/DagActions/inlineMessage.tsx
index 13d80c19acd..51faa21f030 100644
--- a/airflow-core/src/airflow/ui/src/components/DagActions/inlineMessage.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DagActions/inlineMessage.tsx
@@ -16,18 +16,67 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Text, Skeleton } from "@chakra-ui/react";
+import { Text, Skeleton, VStack } from "@chakra-ui/react";
import type { TFunction } from "i18next";
-export const getInlineMessage = (isPendingDryRun: boolean, totalEntries:
number, translate: TFunction) =>
- isPendingDryRun ? (
- <Skeleton height="20px" width="100px" />
- ) : totalEntries === 0 ? (
- <Text color="fg.error" fontSize="sm" fontWeight="medium">
- {translate("backfill.affectedNone")}
- </Text>
- ) : (
+import type { DryRunBackfillResponse } from "openapi/requests/types.gen";
+
+import { PartitionPreviewTable } from "./PartitionPreviewTable";
+
+type InlineMessageOptions = {
+ readonly backfills?: Array<DryRunBackfillResponse>;
+ readonly isPartitioned?: boolean;
+ readonly isPendingDryRun: boolean;
+ readonly totalEntries: number;
+ readonly translate: TFunction;
+};
+
+export const getInlineMessage = ({
+ backfills = [],
+ isPartitioned = false,
+ isPendingDryRun,
+ totalEntries,
+ translate,
+}: InlineMessageOptions) => {
+ if (isPendingDryRun) {
+ return <Skeleton height="20px" width="100px" />;
+ }
+
+ if (isPartitioned) {
+ if (totalEntries === 0) {
+ return (
+ <Text color="fg.error" fontSize="sm" fontWeight="medium">
+ {translate("backfill.partitionsNone")}
+ </Text>
+ );
+ }
+
+ const partitionRows = backfills.filter(
+ (backfill): backfill is { partition_key: string } &
DryRunBackfillResponse =>
+ backfill.partition_key !== null,
+ );
+
+ return (
+ <VStack alignItems="flex-start" gap={1}>
+ <Text color="fg.success" fontSize="sm">
+ {translate("backfill.partitionsAffected", { count: totalEntries })}
+ </Text>
+ <PartitionPreviewTable backfills={partitionRows} />
+ </VStack>
+ );
+ }
+
+ if (totalEntries === 0) {
+ return (
+ <Text color="fg.error" fontSize="sm" fontWeight="medium">
+ {translate("backfill.affectedNone")}
+ </Text>
+ );
+ }
+
+ return (
<Text color="fg.success" fontSize="sm">
{translate("backfill.affected", { count: totalEntries })}
</Text>
);
+};