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 b767477397d Fix partition key display and input handling (#69702)
b767477397d is described below
commit b767477397dc94b30bb51bd2001196b9392c492e
Author: Wei Lee <[email protected]>
AuthorDate: Thu Jul 16 23:05:52 2026 +0800
Fix partition key display and input handling (#69702)
* UI: Fix partition key display and input handling
Non-partitioned Dag runs showed a dangling "Partition key:" label, an
empty partition key typed then cleared was sent to the trigger/materialize
APIs and rejected with 400, the trigger form exposed the field even for
non-partitioned Dags, the manual asset-event partition key used a
JsonEditor for a plain string, and the runs table hid the partition_key
column even when its filter was shown for a partitioned Dag. Also unify
the partition key label so cron/runtime-partitioned runs no longer say
"Mapped Partition key".
* fixup! UI: Fix partition key display and input handling
* fixup! fixup! UI: Fix partition key display and input handling
* UI: Extract shared partition key normalization util
The trigger form and manual/materialize asset-event flows each
duplicated the same undefined/empty-string-to-null check before
sending partition_key to the API, making the normalization easy to
get subtly wrong in one call site while fixing it in another.
* UI: Simplify partition key visibility check on asset event page
Collapse the redundant undefined/null comparison into a single
loose-equality null check, matching the reviewer's simplification
of the earlier partition key display fix.
* UI: Remove partition-key column auto-visibility from the runs table
The runs table hid the partition_key column by default and revealed it
only for partitioned Dags by fetching Dag details and forcing a table
remount once the answer arrived. That remount-on-load approach was
fragile, and the show/hide behaviour is better solved by a reusable
mechanism (one that could also drive e.g. hiding map_index for
non-mapped tasks), so it is dropped here to be reworked separately.
---
.../ui/public/i18n/locales/en/components.json | 1 +
.../ui/src/components/Assets/AssetEvent.test.tsx | 48 +++++
.../ui/src/components/Assets/AssetEvent.tsx | 17 +-
.../TriggerDag/TriggerDAGAdvancedOptions.tsx | 38 ++--
.../components/TriggerDag/TriggerDAGForm.test.tsx | 44 ++++
.../src/components/TriggerDag/TriggerDAGForm.tsx | 2 +-
.../src/pages/Asset/CreateAssetEventModal.test.tsx | 222 +++++++++++++++++++++
.../ui/src/pages/Asset/CreateAssetEventModal.tsx | 13 +-
.../src/airflow/ui/src/pages/Run/Header.test.tsx | 94 +++++++++
.../src/airflow/ui/src/pages/Run/Header.tsx | 2 +-
.../src/airflow/ui/src/queries/useTrigger.ts | 4 +-
airflow-core/src/airflow/ui/src/utils/index.ts | 1 +
.../src/utils/{index.ts => partitionKey.test.ts} | 22 +-
.../ui/src/utils/{index.ts => partitionKey.ts} | 12 +-
14 files changed, 468 insertions(+), 52 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 350e3898a39..10351910eee 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
@@ -136,6 +136,7 @@
"loading": "Loading Dag information...",
"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",
"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/Assets/AssetEvent.test.tsx
b/airflow-core/src/airflow/ui/src/components/Assets/AssetEvent.test.tsx
new file mode 100644
index 00000000000..c8856d0eab9
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Assets/AssetEvent.test.tsx
@@ -0,0 +1,48 @@
+/*!
+ * 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 { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import type { AssetEventResponse } from "openapi/requests/types.gen";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { AssetEvent } from "./AssetEvent";
+
+const baseEvent = {
+ asset_id: 1,
+ created_dagruns: [],
+ id: 1,
+ source_map_index: -1,
+ timestamp: "2025-01-01T00:00:00Z",
+} satisfies Partial<AssetEventResponse> as AssetEventResponse;
+
+describe("AssetEvent", () => {
+ it("does not render a partition key line when partition_key is null", () => {
+ render(<AssetEvent assetId={1} event={{ ...baseEvent, partition_key: null
}} />, { wrapper: Wrapper });
+
+
expect(screen.queryByText(/dagRun\.partitionKey/u)).not.toBeInTheDocument();
+ });
+
+ it("renders the partition key line when partition_key is set", () => {
+ render(<AssetEvent assetId={1} event={{ ...baseEvent, partition_key: "foo"
}} />, { wrapper: Wrapper });
+
+ expect(screen.getByText("dagRun.partitionKey: foo")).toBeInTheDocument();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/Assets/AssetEvent.tsx
b/airflow-core/src/airflow/ui/src/components/Assets/AssetEvent.tsx
index b7d4b62d265..91baa8601f3 100644
--- a/airflow-core/src/airflow/ui/src/components/Assets/AssetEvent.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Assets/AssetEvent.tsx
@@ -95,13 +95,16 @@ export const AssetEvent = ({
<HStack>
<TriggeredRuns dagRuns={event.created_dagruns} />
</HStack>
- {event.partition_key === undefined ? undefined : (
- <HStack>
- <Text>
- {rootTranslate("dagRun.partitionKey")}: {event.partition_key}
- </Text>
- </HStack>
- )}
+ {
+ // eslint-disable-next-line no-eq-null, eqeqeq
+ event.partition_key == null ? undefined : (
+ <HStack>
+ <Text>
+ {rootTranslate("dagRun.partitionKey")}: {event.partition_key}
+ </Text>
+ </HStack>
+ )
+ }
{Object.keys(extra).length >= 1 ? <RenderedJsonField collapsed
content={extra} /> : undefined}
</Box>
);
diff --git
a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGAdvancedOptions.tsx
b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGAdvancedOptions.tsx
index 77d16664e52..d5512579093 100644
---
a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGAdvancedOptions.tsx
+++
b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGAdvancedOptions.tsx
@@ -25,9 +25,10 @@ import type { DagRunTriggerParams } from "./types";
type TriggerDAGAdvancedOptionsProps = {
readonly control: Control<DagRunTriggerParams>;
+ readonly isPartitioned: boolean;
};
-const TriggerDAGAdvancedOptions = ({ control }:
TriggerDAGAdvancedOptionsProps) => {
+const TriggerDAGAdvancedOptions = ({ control, isPartitioned }:
TriggerDAGAdvancedOptionsProps) => {
const { t: translate } = useTranslation(["common", "components"]);
const { t: rootTranslate } = useTranslation();
@@ -51,22 +52,25 @@ const TriggerDAGAdvancedOptions = ({ control }:
TriggerDAGAdvancedOptionsProps)
)}
/>
- <Controller
- control={control}
- name="partitionKey"
- render={({ field }) => (
- <Field.Root mt={6} orientation="horizontal">
- <Stack>
- <Field.Label fontSize="md" style={{ flexBasis: "30%" }}>
- {rootTranslate("dagRun.partitionKey")}
- </Field.Label>
- </Stack>
- <Stack css={{ flexBasis: "70%" }}>
- <Input {...field} size="sm" />
- </Stack>
- </Field.Root>
- )}
- />
+ {isPartitioned ? (
+ <Controller
+ control={control}
+ name="partitionKey"
+ render={({ field }) => (
+ <Field.Root mt={6} orientation="horizontal">
+ <Stack>
+ <Field.Label fontSize="md" style={{ flexBasis: "30%" }}>
+ {rootTranslate("dagRun.partitionKey")}
+ </Field.Label>
+ </Stack>
+ <Stack css={{ flexBasis: "70%" }}>
+ <Input {...field} size="sm" />
+
<Field.HelperText>{translate("components:triggerDag.partitionKeyHelp")}</Field.HelperText>
+ </Stack>
+ </Field.Root>
+ )}
+ />
+ ) : undefined}
<Controller
control={control}
name="note"
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 c739b058a87..de15a092d9c 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
@@ -128,4 +128,48 @@ describe("TriggerDAGForm", () => {
expect(configJson.value).toContain('"Updated message"');
});
});
+
+ it("hides the partition key field for non-partitioned Dags", async () => {
+ render(
+ <TriggerDAGForm
+ dagDisplayName="Non Partitioned Dag"
+ dagId="example_non_partitioned_dag"
+ error={undefined}
+ hasSchedule={false}
+ isPartitioned={false}
+ isPaused={false}
+ isPending={false}
+ onSubmitTrigger={vi.fn()}
+ open
+ />,
+ { wrapper: Wrapper },
+ );
+
+ fireEvent.click(screen.getByText("Advanced Options"));
+
+ await waitFor(() => expect(screen.getByText("runId")).toBeInTheDocument());
+ expect(screen.queryByText("dagRun.partitionKey")).not.toBeInTheDocument();
+ });
+
+ it("shows the partition key field for partitioned Dags", async () => {
+ render(
+ <TriggerDAGForm
+ dagDisplayName="Partitioned Dag"
+ dagId="example_partitioned_dag"
+ error={undefined}
+ hasSchedule={false}
+ isPartitioned
+ isPaused={false}
+ isPending={false}
+ onSubmitTrigger={vi.fn()}
+ open
+ />,
+ { wrapper: Wrapper },
+ );
+
+ fireEvent.click(screen.getByText("Advanced Options"));
+
+ await waitFor(() =>
expect(screen.getByText("dagRun.partitionKey")).toBeInTheDocument());
+
expect(screen.getByText("components:triggerDag.partitionKeyHelp")).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 3cac3a7edc1..ccd79c4fc25 100644
--- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx
@@ -244,7 +244,7 @@ const TriggerDAGForm = ({
setErrors={setErrors}
setFormError={setFormError}
>
- <TriggerDAGAdvancedOptions control={control} />
+ <TriggerDAGAdvancedOptions control={control}
isPartitioned={isPartitioned} />
</ConfigForm>
</VStack>
<Box as="footer" display="flex" justifyContent="flex-end" mt={4}>
diff --git
a/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.test.tsx
new file mode 100644
index 00000000000..a7e91e64c05
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.test.tsx
@@ -0,0 +1,222 @@
+/*!
+ * 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 { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type * as OpenapiQueries from "openapi/queries";
+import type { AssetResponse, DAGDetailsResponse } from
"openapi/requests/types.gen";
+import type { DagRunTriggerParams } from "src/components/TriggerDag/types";
+import type * as Ui from "src/components/ui";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { CreateAssetEventModal } from "./CreateAssetEventModal";
+
+const materializeSubmitParams = vi.hoisted<DagRunTriggerParams>(() => ({
+ conf: "{}",
+ dagRunId: "",
+ dataIntervalEnd: "",
+ dataIntervalMode: "auto",
+ dataIntervalStart: "",
+ logicalDate: "",
+ note: "",
+ partitionKey: undefined,
+}));
+
+vi.mock("src/components/ui", async (importOriginal) => {
+ const actual = await importOriginal<typeof Ui>();
+ // Must stay inside the factory: vitest hoists vi.mock above module scope,
so an outer-scope
+ // component cannot be referenced here.
+ // eslint-disable-next-line unicorn/consistent-function-scoping
+ const DialogPart = ({ children }: { readonly children?: ReactNode }) =>
<div>{children}</div>;
+
+ return {
+ ...actual,
+ Dialog: {
+ ...actual.Dialog,
+ Body: DialogPart,
+ CloseTrigger: () => undefined,
+ Content: DialogPart,
+ Footer: DialogPart,
+ Header: DialogPart,
+ Root: ({ children, open }: { readonly children?: ReactNode; readonly
open?: boolean }) =>
+ open ? <div>{children}</div> : undefined,
+ },
+ };
+});
+
+vi.mock("src/components/JsonEditor", () => ({
+ JsonEditor: ({ value = "{}" }: { readonly value?: string }) => (
+ <textarea aria-label="Extra JSON" readOnly value={value} />
+ ),
+}));
+
+vi.mock("src/components/TriggerDag/TriggerDAGForm", () => ({
+ default: ({ onSubmitTrigger }: { readonly onSubmitTrigger: (params:
DagRunTriggerParams) => void }) => (
+ <button onClick={() => onSubmitTrigger(materializeSubmitParams)}
type="button">
+ submit materialize
+ </button>
+ ),
+}));
+
+vi.mock("openapi/queries", async (importOriginal) => {
+ const actual = await importOriginal<typeof OpenapiQueries>();
+
+ return {
+ ...actual,
+ useAssetServiceCreateAssetEvent: vi.fn(),
+ useAssetServiceMaterializeAsset: vi.fn(),
+ useDagServiceGetDagDetails: vi.fn(),
+ useDependenciesServiceGetDependencies: vi.fn(),
+ };
+});
+
+const {
+ useAssetServiceCreateAssetEvent,
+ useAssetServiceMaterializeAsset,
+ useDagServiceGetDagDetails,
+ useDependenciesServiceGetDependencies,
+} = await import("openapi/queries");
+
+const asset = {
+ aliases: [],
+ consuming_tasks: [],
+ created_at: "2025-01-01T00:00:00Z",
+ extra: {},
+ group: "",
+ id: 1,
+ name: "my_asset",
+ producing_tasks: [],
+ scheduled_dags: [],
+ updated_at: "2025-01-01T00:00:00Z",
+ uri: "s3://bucket/my_asset",
+ watchers: [],
+} satisfies AssetResponse;
+
+const createAssetEvent = vi.fn();
+const materializeAsset = vi.fn();
+
+const noUpstreamDependencies = {
+ data: { edges: [], nodes: [] },
+} as ReturnType<typeof useDependenciesServiceGetDependencies>;
+
+const withUpstreamDependencies = {
+ data: {
+ edges: [{ source_id: "dag:upstream_dag", target_id: `asset:${asset.id}` }],
+ nodes: [],
+ },
+} as ReturnType<typeof useDependenciesServiceGetDependencies>;
+
+const upstreamDag = {
+ dag_display_name: "Upstream Dag",
+ is_paused: false,
+ timetable_partitioned: true,
+ timetable_summary: null,
+} as unknown as DAGDetailsResponse;
+
+describe("CreateAssetEventModal", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(useAssetServiceCreateAssetEvent).mockReturnValue({
+ error: undefined,
+ isPending: false,
+ mutate: createAssetEvent,
+ } as unknown as ReturnType<typeof useAssetServiceCreateAssetEvent>);
+ vi.mocked(useAssetServiceMaterializeAsset).mockReturnValue({
+ error: undefined,
+ isPending: false,
+ mutate: materializeAsset,
+ } as unknown as ReturnType<typeof useAssetServiceMaterializeAsset>);
+
vi.mocked(useDependenciesServiceGetDependencies).mockReturnValue(noUpstreamDependencies);
+ vi.mocked(useDagServiceGetDagDetails).mockReturnValue({
+ data: undefined,
+ } as ReturnType<typeof useDagServiceGetDagDetails>);
+ });
+
+ it("renders the manual partition key field as a plain text Input, not the
JSON editor", () => {
+ render(<CreateAssetEventModal asset={asset} onClose={vi.fn()} open />, {
wrapper: Wrapper });
+
+ const partitionKeyInput =
screen.getByLabelText("common:dagRun.partitionKey");
+
+ expect(partitionKeyInput.tagName).toBe("INPUT");
+ expect(screen.getByLabelText("Extra JSON").tagName).toBe("TEXTAREA");
+ });
+
+ it("sends partition_key as null when the manual partition key is left
empty", () => {
+ render(<CreateAssetEventModal asset={asset} onClose={vi.fn()} open />, {
wrapper: Wrapper });
+
+ fireEvent.click(screen.getByText("createEvent.button"));
+
+ expect(createAssetEvent).toHaveBeenCalledWith({
+ requestBody: expect.objectContaining({ partition_key: null }) as unknown,
+ });
+ });
+
+ it("sends the entered manual partition key as-is", () => {
+ render(<CreateAssetEventModal asset={asset} onClose={vi.fn()} open />, {
wrapper: Wrapper });
+
+ fireEvent.change(screen.getByLabelText("common:dagRun.partitionKey"), {
+ target: { value: "2025-01-01" },
+ });
+ fireEvent.click(screen.getByText("createEvent.button"));
+
+ expect(createAssetEvent).toHaveBeenCalledWith({
+ requestBody: expect.objectContaining({ partition_key: "2025-01-01" }) as
unknown,
+ });
+ });
+
+ it("sends materialize partition_key as null when the trigger form leaves it
undefined", () => {
+
vi.mocked(useDependenciesServiceGetDependencies).mockReturnValue(withUpstreamDependencies);
+ vi.mocked(useDagServiceGetDagDetails).mockReturnValue({
+ data: upstreamDag,
+ } as ReturnType<typeof useDagServiceGetDagDetails>);
+ materializeSubmitParams.partitionKey = undefined;
+
+ render(<CreateAssetEventModal asset={asset} onClose={vi.fn()} open />, {
wrapper: Wrapper });
+
+ fireEvent.click(screen.getByText("createEvent.materialize.label"));
+ fireEvent.click(screen.getByText("submit materialize"));
+
+ expect(materializeAsset).toHaveBeenCalledWith(
+ expect.objectContaining({
+ requestBody: expect.objectContaining({ partition_key: null }) as
unknown,
+ }),
+ );
+ });
+
+ it("sends the materialize partition_key from the trigger form as-is", () => {
+
vi.mocked(useDependenciesServiceGetDependencies).mockReturnValue(withUpstreamDependencies);
+ vi.mocked(useDagServiceGetDagDetails).mockReturnValue({
+ data: upstreamDag,
+ } as ReturnType<typeof useDagServiceGetDagDetails>);
+ materializeSubmitParams.partitionKey = "2025-02-02";
+
+ render(<CreateAssetEventModal asset={asset} onClose={vi.fn()} open />, {
wrapper: Wrapper });
+
+ fireEvent.click(screen.getByText("createEvent.materialize.label"));
+ fireEvent.click(screen.getByText("submit materialize"));
+
+ expect(materializeAsset).toHaveBeenCalledWith(
+ expect.objectContaining({
+ requestBody: expect.objectContaining({ partition_key: "2025-02-02" })
as unknown,
+ }),
+ );
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.tsx
b/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.tsx
index 24d7d98755a..c952b45b30c 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/CreateAssetEventModal.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Button, Field, Heading, HStack, Text, VStack } from
"@chakra-ui/react";
+import { Button, Field, Heading, HStack, Input, Text, VStack } from
"@chakra-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -45,6 +45,7 @@ import TriggerDAGForm from
"src/components/TriggerDag/TriggerDAGForm";
import type { DagRunTriggerParams } from "src/components/TriggerDag/types";
import { Dialog, toaster } from "src/components/ui";
import { RadioCardItem, RadioCardRoot } from "src/components/ui/RadioCard";
+import { toNullablePartitionKey } from "src/utils";
type Props = {
readonly asset: AssetResponse;
@@ -158,7 +159,7 @@ export const CreateAssetEventModal = ({ asset, onClose,
open }: Props) => {
data_interval_start: dataIntervalStart?.toISOString() ?? null,
logical_date: logicalDate?.toISOString() ?? null,
note: dagRunRequestBody.note === "" ? undefined : dagRunRequestBody.note,
- partition_key: dagRunRequestBody.partitionKey ?? null,
+ partition_key: toNullablePartitionKey(dagRunRequestBody.partitionKey),
};
materializeAsset({
@@ -172,7 +173,7 @@ export const CreateAssetEventModal = ({ asset, onClose,
open }: Props) => {
requestBody: {
asset_id: asset.id,
extra: JSON.parse(extra) as Record<string, unknown>,
- partition_key: partitionKey ?? null,
+ partition_key: toNullablePartitionKey(partitionKey),
},
});
@@ -226,7 +227,11 @@ export const CreateAssetEventModal = ({ asset, onClose,
open }: Props) => {
<>
<Field.Root mt={6}>
<Field.Label
fontSize="md">{translate("common:dagRun.partitionKey")}</Field.Label>
- <JsonEditor onChange={setPartitionKey} value={partitionKey} />
+ <Input
+ onChange={(event) => setPartitionKey(event.target.value)}
+ size="sm"
+ value={partitionKey ?? ""}
+ />
</Field.Root>
<ErrorAlert error={manualError} />
</>
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Header.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Run/Header.test.tsx
new file mode 100644
index 00000000000..a9ba55247b7
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Header.test.tsx
@@ -0,0 +1,94 @@
+/*!
+ * 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 { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type * as OpenapiQueries from "openapi/queries";
+import type { DAGRunResponse } from "openapi/requests/types.gen";
+import i18n from "src/i18n/config";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { Header } from "./Header";
+
+// Action buttons and note preview pull in mutation/permission wiring that is
+// unrelated to the partition key label under test; stub them out so the test
+// only depends on the stats rendered by Header itself.
+vi.mock("src/components/Clear", () => ({ ClearRunButton: () => undefined }));
+vi.mock("src/components/MarkAs", () => ({ MarkRunAsButton: () => undefined }));
+vi.mock("src/components/NeedsReviewButton", () => ({
NeedsReviewButtonWithModal: () => undefined }));
+vi.mock("src/pages/DagRuns/DeleteRunButton", () => ({ default: () => undefined
}));
+vi.mock("src/components/NotePreview", () => ({ default: () => undefined }));
+
+vi.mock("openapi/queries", async (importOriginal) => {
+ const actual = await importOriginal<typeof OpenapiQueries>();
+
+ return {
+ ...actual,
+ useDeadlinesServiceGetDagDeadlineAlerts: vi.fn(),
+ };
+});
+
+const { useDeadlinesServiceGetDagDeadlineAlerts } = await
import("openapi/queries");
+
+const baseDagRun = {
+ bundle_version: null,
+ conf: {},
+ 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_date: null,
+ queued_at: null,
+ run_after: "2025-01-01T00:00:00Z",
+ run_type: "manual",
+ start_date: null,
+ state: "success",
+ triggered_by: null,
+ triggering_user_name: null,
+} satisfies Partial<DAGRunResponse> as unknown as DAGRunResponse;
+
+describe("Header", () => {
+ beforeEach(() => {
+ vi.mocked(useDeadlinesServiceGetDagDeadlineAlerts).mockReturnValue({
+ data: undefined,
+ } as ReturnType<typeof useDeadlinesServiceGetDagDeadlineAlerts>);
+ });
+
+ it("shows the partition key stat using the dagRun.partitionKey label when
partition_key is set", () => {
+ render(<Header dagRun={{ ...baseDagRun, partition_key: "2025-01-01" }} />,
{ wrapper: Wrapper });
+
+
expect(screen.getByText(i18n.t("dagRun.partitionKey"))).toBeInTheDocument();
+ expect(screen.getByText("2025-01-01")).toBeInTheDocument();
+ expect(screen.queryByText(/mappedPartitionKey/iu)).not.toBeInTheDocument();
+ });
+
+ it("hides the partition key stat when partition_key is null", () => {
+ render(<Header dagRun={{ ...baseDagRun, partition_key: null }} />, {
wrapper: Wrapper });
+
+
expect(screen.queryByText(i18n.t("dagRun.partitionKey"))).not.toBeInTheDocument();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
index acba855305f..c31db0e9ac0 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
@@ -75,7 +75,7 @@ export const Header = ({ dagRun }: { readonly dagRun:
DAGRunResponse }) => {
? []
: [
{
- label: translate("dagRun.mappedPartitionKey"),
+ label: translate("dagRun.partitionKey"),
value: dagRun.partition_key,
},
]),
diff --git a/airflow-core/src/airflow/ui/src/queries/useTrigger.ts
b/airflow-core/src/airflow/ui/src/queries/useTrigger.ts
index d96fbf45cd7..6488a1d00a6 100644
--- a/airflow-core/src/airflow/ui/src/queries/useTrigger.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useTrigger.ts
@@ -27,7 +27,7 @@ import type { DagRunTriggerParams } from
"src/components/TriggerDag/types";
import { toaster } from "src/components/ui";
import { SearchParamsKeys } from "src/constants/searchParams";
import { gridQueryKeys } from "src/queries/gridViewQueryKeys";
-import { createErrorToaster } from "src/utils";
+import { createErrorToaster, toNullablePartitionKey } from "src/utils";
export const useTrigger = ({ dagId, onSuccessConfirm }: { dagId: string;
onSuccessConfirm: () => void }) => {
const queryClient = useQueryClient();
@@ -108,7 +108,7 @@ export const useTrigger = ({ dagId, onSuccessConfirm }: {
dagId: string; onSucce
data_interval_start: formattedDataIntervalStart,
logical_date: formattedLogicalDate,
note: checkNote,
- partition_key: dagRunRequestBody.partitionKey ?? null,
+ partition_key: toNullablePartitionKey(dagRunRequestBody.partitionKey),
},
});
};
diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts
b/airflow-core/src/airflow/ui/src/utils/index.ts
index 738b6a31dfe..292a091d38a 100644
--- a/airflow-core/src/airflow/ui/src/utils/index.ts
+++ b/airflow-core/src/airflow/ui/src/utils/index.ts
@@ -21,6 +21,7 @@ export { capitalize } from "./capitalize";
export { getDuration, renderDuration } from "./datetimeUtils";
export { createErrorToaster, getErrorStatus } from "./errorHandling";
export { getMetaKey } from "./getMetaKey";
+export { toNullablePartitionKey } from "./partitionKey";
export { useContainerWidth } from "./useContainerWidth";
export { useDocumentTitle } from "./useDocumentTitle";
export { DocumentTitleProvider } from "./useDocumentTitleProvider";
diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts
b/airflow-core/src/airflow/ui/src/utils/partitionKey.test.ts
similarity index 58%
copy from airflow-core/src/airflow/ui/src/utils/index.ts
copy to airflow-core/src/airflow/ui/src/utils/partitionKey.test.ts
index 738b6a31dfe..4606f63d060 100644
--- a/airflow-core/src/airflow/ui/src/utils/index.ts
+++ b/airflow-core/src/airflow/ui/src/utils/partitionKey.test.ts
@@ -16,14 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { describe, it, expect } from "vitest";
-export { capitalize } from "./capitalize";
-export { getDuration, renderDuration } from "./datetimeUtils";
-export { createErrorToaster, getErrorStatus } from "./errorHandling";
-export { getMetaKey } from "./getMetaKey";
-export { useContainerWidth } from "./useContainerWidth";
-export { useDocumentTitle } from "./useDocumentTitle";
-export { DocumentTitleProvider } from "./useDocumentTitleProvider";
-export { useFiltersHandler, type FilterableSearchParamsKeys } from
"./useFiltersHandler";
-export * from "./query";
-export { STATE_PRIORITY, sortStateEntries } from "./stateUtils";
+import { toNullablePartitionKey } from "./partitionKey";
+
+describe("toNullablePartitionKey", () => {
+ it.each([
+ { description: "undefined", expected: null, input: undefined },
+ { description: "empty string", expected: null, input: "" },
+ { description: "a non-empty string", expected: "2026-07-15", input:
"2026-07-15" },
+ ])("returns $expected for $description", ({ expected, input }) => {
+ expect(toNullablePartitionKey(input)).toBe(expected);
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts
b/airflow-core/src/airflow/ui/src/utils/partitionKey.ts
similarity index 58%
copy from airflow-core/src/airflow/ui/src/utils/index.ts
copy to airflow-core/src/airflow/ui/src/utils/partitionKey.ts
index 738b6a31dfe..8570dafe620 100644
--- a/airflow-core/src/airflow/ui/src/utils/index.ts
+++ b/airflow-core/src/airflow/ui/src/utils/partitionKey.ts
@@ -17,13 +17,5 @@
* under the License.
*/
-export { capitalize } from "./capitalize";
-export { getDuration, renderDuration } from "./datetimeUtils";
-export { createErrorToaster, getErrorStatus } from "./errorHandling";
-export { getMetaKey } from "./getMetaKey";
-export { useContainerWidth } from "./useContainerWidth";
-export { useDocumentTitle } from "./useDocumentTitle";
-export { DocumentTitleProvider } from "./useDocumentTitleProvider";
-export { useFiltersHandler, type FilterableSearchParamsKeys } from
"./useFiltersHandler";
-export * from "./query";
-export { STATE_PRIORITY, sortStateEntries } from "./stateUtils";
+export const toNullablePartitionKey = (partitionKey: string | undefined):
string | null =>
+ partitionKey === undefined || partitionKey === "" ? null : partitionKey;