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;

Reply via email to