dheerajturaga commented on code in PR #75229:
URL: https://github.com/apache/airflow/pull/75229#discussion_r4342720428


##########
airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx:
##########
@@ -100,8 +102,40 @@ const CARD_DEF: CardDef<DAGWithLatestDagRunsResponse> = {
   },
 };
 
+// Extracted so the list's own tree stays within the JSX depth limit, and 
because the bar is a
+// self-contained thing: it only needs the selection, not the table around it.
+const BulkActionBar = ({
+  clearSelections,
+  deselectKeys,
+  open,
+  selectedCount,
+  selectedDags,
+}: {
+  readonly clearSelections: () => void;
+  readonly deselectKeys: (keys: Array<string>) => void;
+  readonly open: boolean;
+  readonly selectedCount: number;
+  readonly selectedDags: Array<DAGWithLatestDagRunsResponse>;
+}) => {
+  const { i18n, t: translate } = useTranslation("dags");

Review Comment:
   Moving this into `BulkActionBar` changed the namespace from the default 
(`common`) to `dags`. `selected` only exists in `common.json` and `admin.json`, 
so `translate("selected")` no longer finds it and i18next shows the raw key. 
English now reads "3 selected" instead of "3 Selected", German reads "3 
selected" instead of "3 Ausgewählt", and the same happens in every other locale.
   
   ```suggestion
     const { i18n, t: translate } = useTranslation();
   ```



##########
airflow-core/docs/howto/custom-action-plugin.rst:
##########
@@ -0,0 +1,331 @@
+ .. 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.
+
+
+Add your own actions to Dags, Dag Runs and Task Instances
+=========================================================
+
+Airflow renders a fixed set of actions for each entity — pause, trigger, 
clear, mark as,
+delete. ``AirflowPlugin.ui_actions`` lets you add your own beside them, 
everywhere Airflow already
+offers actions for that entity:
+
+* the trailing cell of its **table rows**,
+* its **card**, on lists that have a card view, and
+* the action row of its **header card** on the detail page.
+
+An action either opens a URL or mounts a React component of your own. Unlike
+``external_views`` and ``react_apps``, which each take over a whole tab, an 
action is a single
+button — the right size for "open the runbook for this Dag" or "show me this 
task's payload".
+
+Your first action
+-----------------
+
+Drop this in a file under your :doc:`plugins folder 
</administration-and-deployment/plugins>`:
+
+.. code-block:: python
+
+    from airflow.plugins_manager import AirflowPlugin
+
+
+    class RunbookPlugin(AirflowPlugin):
+        name = "runbook_plugin"
+
+        ui_actions = [
+            {
+                "action_type": "link",
+                "name": "Open runbook",
+                "destination": "dag",
+                "href": "https://runbooks.example.com/{DAG_ID}";,
+            }
+        ]
+
+Restart the API server. Every Dag now carries an "Open runbook" button — on 
each row and card
+of the Dags list, and in the header of each Dag's detail page — pointing at 
that Dag's
+runbook.
+
+Four keys are required: ``action_type``, ``name``, ``destination``, and the 
target the kind
+needs (``href`` for ``link``, ``bundle_url`` for ``react``). ``name`` is the 
button's tooltip
+and its accessible label, so write it as a label rather than an identifier.
+
+``destination`` picks the entity, and with it the surfaces the button appears 
on:
+
+.. list-table::
+   :header-rows: 1
+
+   * - ``destination``
+     - Appears on
+     - Template variables
+   * - ``dag``
+     - Dags list rows and cards, Dag detail header
+     - ``{DAG_ID}``
+   * - ``dag_run``
+     - Dag Runs rows, Dag Run detail header
+     - ``{DAG_ID}``, ``{RUN_ID}``
+   * - ``task_instance``
+     - Task Instances rows, Task Instance detail header
+     - ``{DAG_ID}``, ``{RUN_ID}``, ``{TASK_ID}``, ``{MAP_INDEX}``,
+       ``{REGION_ID}``, ``{REGION_INDEX}``
+
+A variable with no value on the current page is left in the URL as-is rather 
than replaced
+with an empty string, so a half-resolved link fails visibly instead of quietly 
pointing
+somewhere else. Values are URL-encoded for you.
+
+``{REGION_ID}`` and ``{REGION_INDEX}`` identify one iteration of a looping 
task, and resolve
+only on rows that have them; on every other task instance they are left in the 
URL like any
+other variable with no value.
+
+Internal and external links
+---------------------------
+
+A link that matches an Airflow UI route navigates inside the app, with no page 
reload:
+
+.. code-block:: python
+
+    {
+        "action_type": "link",
+        "name": "Jump to runs",
+        "destination": "dag",
+        "href": "/dags/{DAG_ID}/runs",
+    }
+
+A path is resolved against the deployment's base path, so the same plugin 
works whether
+Airflow is served from the root or from under a prefix like 
``http://host/airflow`` -- both
+``/dags/{DAG_ID}/runs`` and ``dags/{DAG_ID}/runs`` reach the Dag's runs. You 
do not need to
+read ``[api] base_url`` to build an internal link.
+
+Anything else is external: it opens in a new tab, and a small arrow is badged 
onto the corner
+of its button so people can tell before they click.
+
+"External" is wider than "a different host". A page served by your own 
``fastapi_apps``
+shares Airflow's origin but is not a UI route, so it counts as external too — 
following it in
+the same tab would drop the user out of the UI and lose their place in 
whatever list they were
+looking at. You do not configure any of this; it is decided from the URL.
+
+Limiting an action to the Dags that need it
+-------------------------------------------
+
+An action with no scoping appears on every entity of its kind, which gets 
noisy fast. The
+optional ``applies_to`` block is the same one external views and React apps 
use:
+
+.. code-block:: python
+
+    {
+        "action_type": "link",
+        "name": "Open runbook",
+        "destination": "dag",
+        "href": "https://runbooks.example.com/{DAG_ID}";,
+        "applies_to": {"dag.tags.name": ["production"]},
+    }
+
+Keys are dotted field paths into the records the surface has, so anything the 
REST API returns
+for the entity is addressable. The case this is most often wanted for is state:
+
+.. code-block:: python
+
+    {
+        "action_type": "link",
+        "name": "Investigate failure",
+        "destination": "task_instance",
+        "href": 
"https://ai.example.com/investigate?dag={DAG_ID}&task={TASK_ID}&run={RUN_ID}";,
+        # Offered on failed task instances and nowhere else.
+        "applies_to": {"state": ["failed", "upstream_failed"]},
+    }
+
+Values are OR-ed within a path and AND-ed across paths, and a path whose root 
record the
+current surface cannot resolve is skipped rather than failing the match. See
+:ref:`Scoping a view to specific Dags and tasks <plugin-scoping>` for the full 
semantics.
+
+One consequence is worth knowing before you rely on it. On the deployment-wide 
**Dag Runs**
+and **Task Instances** lists, each row is its own record but Airflow has not 
loaded the parent
+Dag, so a ``dag.*`` path cannot be judged there and is skipped — the action 
shows on every
+row. Paths rooted at the row itself, like ``state`` or ``dag_id``, are 
evaluated normally, so
+pair them if that matters:
+
+.. code-block:: python
+
+    "applies_to": {"dag.tags.name": ["production"], "dag_id": ["checkout_etl"]}
+
+Everywhere else — the Dags list and all three header cards — the Dag is 
available.
+
+Actions that open a surface
+---------------------------
+
+.. warning::
+  React plugins are experimental. The feature may change in future versions 
without warning
+  based on user feedback and errors reported.
+
+|experimental|
+
+A ``react`` action mounts a component you supply. Airflow renders **only the 
button** — your
+component renders its own surface, so it can be a popover, a modal, a side 
drawer or anything
+else, and it owns its own appearance and accessibility.
+
+.. code-block:: python
+
+    {
+        "action_type": "react",
+        "name": "Inspect payload",
+        "destination": "task_instance",
+        "bundle_url": "https://example.com/static/js/inspect_payload.js";,
+    }
+
+Alongside the entity context props — the same ones documented in
+:ref:`React app context props <plugin-react-props>` — your component receives 
three props for
+driving its surface:
+
+``open``
+    Whether the button is currently toggled on. Your component is mounted the 
first time the
+    button is clicked and stays mounted afterwards, so it can animate out and 
keep its state;
+    follow ``open`` rather than assuming you are unmounted when closed.
+
+``onClose``
+    Call it to toggle the button back off — after submitting, or when your 
surface is
+    dismissed by a backdrop click or Escape.
+
+``triggerRef``
+    A ref to the button element. A popover needs it to anchor against, since 
the button
+    belongs to Airflow rather than to you.
+
+Note the context comes from the **entity whose button was clicked**, not from 
the page's URL.
+A row's action lives on a list page whose URL says nothing about that row, so 
read ``dagId``,
+``runId`` and ``taskId`` from props rather than from the router.
+
+A minimal component, with no build step, borrowing React and Chakra from the 
host — enough to
+see the contract working before you reach for tooling:
+
+.. code-block:: javascript
+
+    const React = globalThis.React;
+    const { Popover, Portal, Text } = globalThis.ChakraUI;
+    const h = React.createElement;
+
+    globalThis["Inspect payload"] = ({ onClose, open, taskId, triggerRef }) =>
+      h(
+        Popover.Root,
+        {
+          onOpenChange: (event) => { if (!event.open) onClose(); },
+          open: Boolean(open),
+          positioning: {
+            getAnchorRect: () => triggerRef?.current?.getBoundingClientRect() 
?? null,
+            placement: "bottom-end",
+          },
+        },
+        h(Portal, null,
+          h(Popover.Positioner, null,
+            h(Popover.Content, null,
+              h(Popover.Body, null, h(Text, null, `Task: ${taskId}`)),
+            ),
+          ),
+        ),
+      );
+
+The bundle must register a function component on ``globalThis`` under the 
action's ``name``.
+Airflow also accepts the shared ``globalThis.AirflowPlugin`` global, which is 
what the
+bootstrap tool below produces.
+
+For anything beyond a demo, use the React plugin bootstrap tool described in
+:doc:`custom-view-plugin` — it generates a Vite project with the bundling 
configuration
+Airflow needs, and shares React with the host rather than shipping its own 
copy.
+
+Two rules for React actions
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+**Give every React action a distinct name.** The component is registered on 
the page under
+its ``name``, so two React actions sharing one — or a React action sharing a 
name with a
+``react_apps`` entry — would render each other's component. Airflow warns 
about this at load
+time and skips the action rather than letting it happen. A ``react_apps`` 
entry always keeps
+the name; between two actions, the plugin that loads first keeps it. The name 
also cannot be
+blank or ``AirflowPlugin``, which is the shared slot Airflow clears before 
each bundle loads.

Review Comment:
   `name` is also the button's label, so it will often be an ordinary word, and 
several ordinary words are already browser globals: `Comment`, `File`, 
`History`, `Image`, `Notification`, `Plugin`, `Request`, `Text`.
   
   `loadPlugin` checks `globalThis[name]` before it checks 
`globalThis.AirflowPlugin`. So a bootstrap-tool bundle with a name like that 
gets the browser's constructor back, the error boundary catches the throw, and 
the button silently does nothing. A bundle that registers under its name 
instead overwrites that browser global for the whole page.
   
   `react_apps` share this problem through the same `loadPlugin`, so a separate 
place to register plugin components may be better as a follow-up. Could this 
paragraph at least say that the name must not reuse an existing browser global?



##########
airflow-core/src/airflow/ui/src/components/PluginActions/PluginActionBoundary.tsx:
##########
@@ -0,0 +1,62 @@
+/*!
+ * 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 { Component, type ErrorInfo, type ReactNode } from "react";
+
+type Props = {
+  readonly children: ReactNode;
+  /** Named in the console message, so the failing plugin is identifiable. */
+  readonly name: string;
+};
+
+/**
+ * Keeps a plugin component's render failure inside the plugin's own slot.
+ *
+ * Without this the error reaches the route's error element and replaces the 
whole list or
+ * detail page, so one plugin takes down the page it was only meant to add a 
button to. A
+ * plausible way in: an action named `History` or `Image` finds the browser's 
own constructor
+ * already on `globalThis`, so the bundle is never loaded and React throws 
calling it.

Review Comment:
   Nit: `loadPlugin` always imports the bundle. What goes wrong is the lookup 
afterwards, which finds the browser's constructor first.
   
   ```suggestion
    * already on `globalThis`, which shadows the bundle's own registration, and 
React throws calling it.
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to