Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions airflow-core/docs/administration-and-deployment/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------------------------

Expand Down
144 changes: 144 additions & 0 deletions airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};

vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof ReactRouterDom>()),
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<string, unknown>)[reactApp.name] = (props: PluginProps) => {
capturedProps = props;

return null;
};

render(<ReactPlugin reactApp={reactApp} />, { wrapper: Wrapper });
};

describe("ReactPlugin context props", () => {
beforeEach(() => {
mockParams = {};
});

afterEach(() => {
(globalThis as Record<string, unknown>)[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);
});
});
78 changes: 56 additions & 22 deletions airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PluginProps>;
Expand Down Expand Up @@ -67,32 +82,58 @@ 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,
{ enabled: Boolean(assetId) },
);
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) },
);
Comment thread
dheerajturaga marked this conversation as resolved.

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<string, unknown>)[reactApp.name];

if (typeof existing === "function") {
const Plugin = existing as PluginComponentType;

return (
<Plugin
assetId={assetId}
assetUri={assetUri}
dagId={dagId}
mapIndex={mapIndex}
runId={runId}
taskId={taskId}
/>
);
return <Plugin {...pluginProps} />;
}

// Otherwise, lazy-load the bundle once. When it resolves, it must set a function component
Expand All @@ -101,14 +142,7 @@ export const ReactPlugin = ({ reactApp }: { readonly reactApp: ReactAppResponse

return (
<Suspense fallback={<Spinner />}>
<LazyPlugin
assetId={assetId}
assetUri={assetUri}
dagId={dagId}
mapIndex={mapIndex}
runId={runId}
taskId={taskId}
/>
<LazyPlugin {...pluginProps} />
</Suspense>
);
};
Loading