From 73e2d55ac2b45c98bbe9fc970a9e60dba4551ef5 Mon Sep 17 00:00:00 2001 From: Dheeraj Turaga Date: Mon, 20 Jul 2026 18:56:00 -0500 Subject: [PATCH] UI: Pass Dag, DagRun, TaskInstance, and Asset objects to React app plugins Follows the pattern established in #69986, which enriched the props passed to React app plugins beyond bare route params by resolving assetUri from the UI's TanStack Query cache. This extends that pattern to the full context objects: React plugins now receive dag, dagRun, taskInstance, and asset as typed props alongside the existing route-id strings. Each object is resolved from the query cache and gated on the route params it depends on, so it is served from the cache the parent details page already populated (no extra request) where present, and is undefined on routes or destinations that lack those ids (e.g. nav, base, dashboard). --- .../administration-and-deployment/plugins.rst | 21 +++ .../airflow/ui/src/pages/ReactPlugin.test.tsx | 144 ++++++++++++++++++ .../src/airflow/ui/src/pages/ReactPlugin.tsx | 78 +++++++--- 3 files changed, 221 insertions(+), 22 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx diff --git a/airflow-core/docs/administration-and-deployment/plugins.rst b/airflow-core/docs/administration-and-deployment/plugins.rst index 2766da35bb130..31c6f16fd0e5e 100644 --- a/airflow-core/docs/administration-and-deployment/plugins.rst +++ b/airflow-core/docs/administration-and-deployment/plugins.rst @@ -301,6 +301,27 @@ definitions in Airflow. .. seealso:: :doc:`/howto/define-extra-link` +React app context props +----------------------- + +.. note:: + The React app integration is experimental and these props may change in future versions. + +Unlike an external view, which only receives context through ``{DAG_ID}``-style tokens in its +``bundle_url``, a React app is rendered as a component and receives context directly as props. +The props available depend on where the app is mounted (its ``destination`` and route): + +- ``dagId``, ``runId``, ``taskId``, ``mapIndex``, ``assetId`` — the identifiers from the current + route (strings), when present. +- ``assetUri`` — the URI of the current asset, when mounted on an asset route. +- ``dag``, ``dagRun``, ``taskInstance``, ``asset`` — the full records for the current route, + matching the corresponding REST API response schemas + (``DAGDetailsResponse``, ``DAGRunResponse``, ``TaskInstanceResponse``, ``AssetResponse``). + Each object is only provided once the identifiers it depends on are present in the route, and + is served from the UI's query cache the details page has already populated (no extra request). + On routes or ``destination`` values without those identifiers (e.g. ``nav``, ``base``, + ``dashboard``), the corresponding objects are ``undefined``. + Exclude views from CSRF protection ---------------------------------- diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx new file mode 100644 index 0000000000000..7f0fb6d56098a --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx @@ -0,0 +1,144 @@ +/*! + * 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 } from "@testing-library/react"; +import type * as ReactRouterDom from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + AssetResponse, + DAGDetailsResponse, + DAGRunResponse, + ReactAppResponse, + TaskInstanceResponse, +} from "openapi/requests/types.gen"; +import { Wrapper } from "src/utils/Wrapper"; + +import { ReactPlugin, type PluginProps } from "./ReactPlugin"; + +const mockAsset = { id: 1, name: "my_asset", uri: "s3://bucket/key" } as AssetResponse; +const mockDag = { dag_display_name: "My Dag", dag_id: "my_dag" } as DAGDetailsResponse; +const mockDagRun = { dag_id: "my_dag", dag_run_id: "run_1", state: "success" } as DAGRunResponse; +const mockTaskInstance = { dag_id: "my_dag", task_id: "my_task" } as TaskInstanceResponse; + +let mockParams: Record = {}; + +vi.mock("react-router-dom", async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => mockParams, +})); + +type QueryOptions = { enabled?: boolean }; + +// Each mocked hook mirrors the real gating: it returns its record only when the caller enables +// the query (i.e. the route params it depends on are present), and undefined otherwise. +vi.mock("openapi/queries", () => ({ + useAssetServiceGetAsset: (_params: unknown, _key: unknown, options?: QueryOptions) => ({ + data: options?.enabled ? mockAsset : undefined, + }), + useDagRunServiceGetDagRun: (_params: unknown, _key: unknown, options?: QueryOptions) => ({ + data: options?.enabled ? mockDagRun : undefined, + }), + useDagServiceGetDagDetails: (_params: unknown, _key: unknown, options?: QueryOptions) => ({ + data: options?.enabled ? mockDag : undefined, + }), + useTaskInstanceServiceGetMappedTaskInstance: (_params: unknown, _key: unknown, options?: QueryOptions) => ({ + data: options?.enabled ? mockTaskInstance : undefined, + }), +})); + +const reactApp = { + bundle_url: "http://localhost/plugin.js", + destination: "dag", + name: "TestPlugin", +} as ReactAppResponse; + +let capturedProps: PluginProps | undefined; + +const renderPlugin = () => { + capturedProps = undefined; + // Pre-register the component so ReactPlugin renders it directly instead of lazy-loading a bundle. + (globalThis as Record)[reactApp.name] = (props: PluginProps) => { + capturedProps = props; + + return null; + }; + + render(, { wrapper: Wrapper }); +}; + +describe("ReactPlugin context props", () => { + beforeEach(() => { + mockParams = {}; + }); + + afterEach(() => { + (globalThis as Record)[reactApp.name] = undefined; + }); + + it("passes no context objects when the route has no params (base/dashboard mounts)", () => { + renderPlugin(); + + expect(capturedProps?.dag).toBeUndefined(); + expect(capturedProps?.dagRun).toBeUndefined(); + expect(capturedProps?.taskInstance).toBeUndefined(); + expect(capturedProps?.asset).toBeUndefined(); + }); + + it("passes the cached Dag object when dagId is in the route", () => { + mockParams = { dagId: "my_dag" }; + + renderPlugin(); + + expect(capturedProps?.dagId).toBe("my_dag"); + expect(capturedProps?.dag).toStrictEqual(mockDag); + expect(capturedProps?.dagRun).toBeUndefined(); + expect(capturedProps?.taskInstance).toBeUndefined(); + }); + + it("passes the cached DagRun object when dagId and runId are in the route", () => { + mockParams = { dagId: "my_dag", runId: "run_1" }; + + renderPlugin(); + + expect(capturedProps?.dag).toStrictEqual(mockDag); + expect(capturedProps?.dagRun).toStrictEqual(mockDagRun); + expect(capturedProps?.taskInstance).toBeUndefined(); + }); + + it("passes the cached TaskInstance object when dagId, runId and taskId are in the route", () => { + mockParams = { dagId: "my_dag", runId: "run_1", taskId: "my_task" }; + + renderPlugin(); + + expect(capturedProps?.dag).toStrictEqual(mockDag); + expect(capturedProps?.dagRun).toStrictEqual(mockDagRun); + expect(capturedProps?.taskInstance).toStrictEqual(mockTaskInstance); + }); + + it("passes the cached Asset object and its uri when assetId is in the route", () => { + mockParams = { assetId: "1" }; + + renderPlugin(); + + expect(capturedProps?.assetId).toBe("1"); + expect(capturedProps?.asset).toStrictEqual(mockAsset); + expect(capturedProps?.assetUri).toBe(mockAsset.uri); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx index 402dddeeceae2..a14fdcb13d3b0 100644 --- a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx +++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx @@ -20,18 +20,33 @@ import { Spinner } from "@chakra-ui/react"; import { type FC, lazy, Suspense } from "react"; import { useParams } from "react-router-dom"; -import { useAssetServiceGetAsset } from "openapi/queries"; -import type { ReactAppResponse } from "openapi/requests/types.gen"; +import { + useAssetServiceGetAsset, + useDagRunServiceGetDagRun, + useDagServiceGetDagDetails, + useTaskInstanceServiceGetMappedTaskInstance, +} from "openapi/queries"; +import type { + AssetResponse, + DAGDetailsResponse, + DAGRunResponse, + ReactAppResponse, + TaskInstanceResponse, +} from "openapi/requests/types.gen"; import { ErrorPage } from "./Error"; export type PluginProps = { + asset?: AssetResponse; assetId?: string; assetUri?: string; + dag?: DAGDetailsResponse; dagId?: string; + dagRun?: DAGRunResponse; mapIndex?: string; runId?: string; taskId?: string; + taskInstance?: TaskInstanceResponse; }; type PluginComponentType = FC; @@ -67,8 +82,9 @@ const loadPlugin = (reactApp: ReactAppResponse): Promise<{ default: PluginCompon export const ReactPlugin = ({ reactApp }: { readonly reactApp: ReactAppResponse }) => { const { assetId, dagId, mapIndex, runId, taskId } = useParams(); - // The asset URI is not part of the route, so resolve it from the asset record. This is a - // cache hit because the asset details page has already fetched it. + // Context objects are not part of the route, so resolve them from the query cache. Each query + // is gated on the route params it needs, so it stays disabled where those params are absent + // (e.g. base/dashboard mounts) and is a cache hit where the parent details page already fetched. const { data: asset } = useAssetServiceGetAsset( { assetId: assetId === undefined ? 0 : parseInt(assetId, 10) }, undefined, @@ -76,6 +92,40 @@ export const ReactPlugin = ({ reactApp }: { readonly reactApp: ReactAppResponse ); const assetUri = asset?.uri; + const { data: dag } = useDagServiceGetDagDetails({ dagId: dagId ?? "" }, undefined, { + enabled: Boolean(dagId), + }); + + const { data: dagRun } = useDagRunServiceGetDagRun( + { dagId: dagId ?? "", dagRunId: runId ?? "" }, + undefined, + { enabled: Boolean(dagId) && Boolean(runId) }, + ); + + const { data: taskInstance } = useTaskInstanceServiceGetMappedTaskInstance( + { + dagId: dagId ?? "", + dagRunId: runId ?? "", + mapIndex: mapIndex === undefined ? -1 : parseInt(mapIndex, 10), + taskId: taskId ?? "", + }, + undefined, + { enabled: Boolean(dagId) && Boolean(runId) && Boolean(taskId) }, + ); + + const pluginProps: PluginProps = { + asset, + assetId, + assetUri, + dag, + dagId, + dagRun, + mapIndex, + runId, + taskId, + taskInstance, + }; + // If the plugin component was already registered on the global object by a previous load, // render it directly without going through Suspense/lazy (avoids flashing the spinner). const existing = (globalThis as Record)[reactApp.name]; @@ -83,16 +133,7 @@ export const ReactPlugin = ({ reactApp }: { readonly reactApp: ReactAppResponse if (typeof existing === "function") { const Plugin = existing as PluginComponentType; - return ( - - ); + return ; } // Otherwise, lazy-load the bundle once. When it resolves, it must set a function component @@ -101,14 +142,7 @@ export const ReactPlugin = ({ reactApp }: { readonly reactApp: ReactAppResponse return ( }> - + ); };