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 ( }> - + ); };