Repository: apache/airflow

Issue:
Display task documentation when hovering on the task
### Description

It would be great if task documentation wouldn't be hidden within the task instance page, but rather displayed when hovering over the task.

### Use case/motivation

In complex DAGs, having access to this allows access to documentation with a few less clicks per task

### Related issues

_No response_

### Are you willing to submit a PR?

- [ ] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)



Discussion:
Thanks for opening your first issue here! Be sure to follow the issue template!

I agree we should make the documentation more visible. But I don't think the tooltip is the right place. It already has a lot in it. I do want to make the task instance page more visible. 

The next UI task is displaying the tree view side-by-side with others so it is easier to see how everything is related. The Task Instance page will certainly be part of that.

Resolving patch:
diff --git a/airflow/www/utils.py b/airflow/www/utils.py
--- a/airflow/www/utils.py
+++ b/airflow/www/utils.py
@@ -110,6 +110,7 @@ def get_mapped_summary(parent_instance, task_instances):
     )
 
     return {
+        'dag_id': parent_instance.dag_id,
         'task_id': parent_instance.task_id,
         'run_id': parent_instance.run_id,
         'state': group_state,
@@ -160,6 +161,7 @@ def encode_dag_run(dag_run: Optional[models.DagRun]) -> Optional[Dict[str, Any]]
         'data_interval_start': datetime_to_string(dag_run.data_interval_start),
         'data_interval_end': datetime_to_string(dag_run.data_interval_end),
         'run_type': dag_run.run_type,
+        'last_scheduling_decision': datetime_to_string(dag_run.last_scheduling_decision),
     }
 
 
diff --git a/airflow/www/views.py b/airflow/www/views.py
--- a/airflow/www/views.py
+++ b/airflow/www/views.py
@@ -528,6 +528,21 @@ def get_task_stats_from_query(qry):
     return data
 
 
+def redirect_or_json(origin, msg, status=""):
+    """
+    Some endpoints are called by javascript,
+    returning json will allow us to more elegantly handle side-effects in-page
+    """
+    if request.headers.get('Accept') == 'application/json':
+        return {'status': status, 'message': msg}
+    else:
+        if status:
+            flash(msg, status)
+        else:
+            flash(msg)
+        return redirect(origin)
+
+
 ######################################################################################
 #                                    Error handlers
 ######################################################################################
@@ -1735,17 +1750,14 @@ def run(self, session=None):
         executor = ExecutorLoader.get_default_executor()
 
         if not getattr(executor, "supports_ad_hoc_ti_run", False):
-            flash("Only works with the Celery, CeleryKubernetes or Kubernetes executors, sorry", "error")
-            return redirect(origin)
+            msg = "Only works with the Celery, CeleryKubernetes or Kubernetes executors, sorry"
+            return redirect_or_json(origin, msg, "error")
 
         dag_run = dag.get_dagrun(run_id=dag_run_id)
         ti = dag_run.get_task_instance(task_id=task.task_id, map_index=map_index)
         if not ti:
-            flash(
-                "Could not queue task instance for execution, task instance is missing",
-                "error",
-            )
-            return redirect(origin)
+            msg = "Could not queue task instance for execution, task instance is missing"
+            return redirect_or_json(origin, msg, "error")
 
         ti.refresh_from_task(task)
 
@@ -1759,11 +1771,8 @@ def run(self, session=None):
         failed_deps = list(ti.get_failed_dep_statuses(dep_context=dep_context))
         if failed_deps:
             failed_deps_str = ", ".join(f"{dep.dep_name}: {dep.reason}" for dep in failed_deps)
-            flash(
-                f"Could not queue task instance for execution, dependencies not met: {failed_deps_str}",
-                "error",
-            )
-            return redirect(origin)
+            msg = f"Could not queue task instance for execution, dependencies not met: {failed_deps_str}"
+            return redirect_or_json(origin, msg, "error")
 
         executor.job_id = "manual"
         executor.start()
@@ -1776,8 +1785,8 @@ def run(self, session=None):
         executor.heartbeat()
         ti.queued_dttm = timezone.utcnow()
         session.merge(ti)
-        flash(f"Sent {ti} to the message queue, it should start any moment now.")
-        return redirect(origin)
+        msg = f"Sent {ti} to the message queue, it should start any moment now."
+        return redirect_or_json(origin, msg)
 
     @expose('/delete', methods=['POST'])
     @auth.has_access(
@@ -1944,7 +1953,14 @@ def trigger(self, session=None):
         return redirect(origin)
 
     def _clear_dag_tis(
-        self, dag, start_date, end_date, origin, recursive=False, confirmed=False, only_failed=False
+        self,
+        dag,
+        start_date,
+        end_date,
+        origin,
+        recursive=False,
+        confirmed=False,
+        only_failed=False,
     ):
         if confirmed:
             count = dag.clear(
@@ -1955,8 +1971,8 @@ def _clear_dag_tis(
                 only_failed=only_failed,
             )
 
-            flash(f"{count} task instances have been cleared")
-            return redirect(origin)
+            msg = f"{count} task instances have been cleared"
+            return redirect_or_json(origin, msg)
 
         try:
             tis = dag.clear(
@@ -1968,19 +1984,22 @@ def _clear_dag_tis(
                 dry_run=True,
             )
         except AirflowException as ex:
-            flash(str(ex), 'error')
-            return redirect(origin)
+            return redirect_or_json(origin, msg=str(ex), status="error")
 
         if not tis:
-            flash("No task instances to clear", 'error')
-            response = redirect(origin)
+            msg = "No task instances to clear"
+            return redirect_or_json(origin, msg, status="error")
+        elif request.headers.get('Accept') == 'application/json':
+            details = [str(t) for t in tis]
+
+            return htmlsafe_json_dumps(details, separators=(',', ':'))
         else:
             details = "\n".join(str(t) for t in tis)
 
             response = self.render_template(
                 'airflow/confirm.html',
                 endpoint=None,
-                message="Here's the list of task instances you are about to clear:",
+                message="Task instances you are about to clear:",
                 details=details,
             )
 
@@ -2040,7 +2059,6 @@ def clear(self):
     def dagrun_clear(self):
         """Clears the DagRun"""
         dag_id = request.form.get('dag_id')
-        origin = get_safe_url(request.form.get('origin'))
         dag_run_id = request.form.get('dag_run_id')
         confirmed = request.form.get('confirmed') == "true"
 
@@ -2049,7 +2067,14 @@ def dagrun_clear(self):
         start_date = dr.logical_date
         end_date = dr.logical_date
 
-        return self._clear_dag_tis(dag, start_date, end_date, origin, recursive=True, confirmed=confirmed)
+        return self._clear_dag_tis(
+            dag,
+            start_date,
+            end_date,
+            origin=None,
+            recursive=True,
+            confirmed=confirmed,
+        )
 
     @expose('/blocked', methods=['POST'])
     @auth.has_access(
@@ -2097,89 +2122,60 @@ def blocked(self, session=None):
             )
         return wwwutils.json_response(payload)
 
-    def _mark_dagrun_state_as_failed(self, dag_id, dag_run_id, confirmed, origin):
+    def _mark_dagrun_state_as_failed(self, dag_id, dag_run_id, confirmed):
         if not dag_run_id:
-            flash('Invalid dag_run_id', 'error')
-            return redirect(origin)
+            return {'status': 'error', 'message': 'Invalid dag_run_id'}
 
         dag = current_app.dag_bag.get_dag(dag_id)
 
         if not dag:
-            flash(f'Cannot find DAG: {dag_id}', 'error')
-            return redirect(origin)
+            return {'status': 'error', 'message': f'Cannot find DAG: {dag_id}'}
 
         new_dag_state = set_dag_run_state_to_failed(dag=dag, run_id=dag_run_id, commit=confirmed)
 
         if confirmed:
-            flash(f'Marked failed on {len(new_dag_state)} task instances')
-            return redirect(origin)
-
+            return {'status': 'success', 'message': f'Marked failed on {len(new_dag_state)} task instances'}
         else:
-            details = '\n'.join(str(t) for t in new_dag_state)
-
-            response = self.render_template(
-                'airflow/confirm.html',
-                message="Here's the list of task instances you are about to mark as failed or skipped",
-                details=details,
-            )
+            details = [str(t) for t in new_dag_state]
 
-            return response
+            return htmlsafe_json_dumps(details, separators=(',', ':'))
 
-    def _mark_dagrun_state_as_success(self, dag_id, dag_run_id, confirmed, origin):
+    def _mark_dagrun_state_as_success(self, dag_id, dag_run_id, confirmed):
         if not dag_run_id:
-            flash('Invalid dag_run_id', 'error')
-            return redirect(origin)
+            return {'status': 'error', 'message': 'Invalid dag_run_id'}
 
         dag = current_app.dag_bag.get_dag(dag_id)
 
         if not dag:
-            flash(f'Cannot find DAG: {dag_id}', 'error')
-            return redirect(origin)
+            return {'status': 'error', 'message': f'Cannot find DAG: {dag_id}'}
 
         new_dag_state = set_dag_run_state_to_success(dag=dag, run_id=dag_run_id, commit=confirmed)
 
         if confirmed:
-            flash(f'Marked success on {len(new_dag_state)} task instances')
-            return redirect(origin)
-
+            return {'status': 'success', 'message': f'Marked success on {len(new_dag_state)} task instances'}
         else:
-            details = '\n'.join(str(t) for t in new_dag_state)
-
-            response = self.render_template(
-                'airflow/confirm.html',
-                message="Here's the list of task instances you are about to mark as success",
-                details=details,
-            )
+            details = [str(t) for t in new_dag_state]
 
-            return response
+            return htmlsafe_json_dumps(details, separators=(',', ':'))
 
-    def _mark_dagrun_state_as_queued(self, dag_id: str, dag_run_id: str, confirmed: bool, origin: str):
+    def _mark_dagrun_state_as_queued(self, dag_id: str, dag_run_id: str, confirmed: bool):
         if not dag_run_id:
-            flash('Invalid dag_run_id', 'error')
-            return redirect(origin)
+            return {'status': 'error', 'message': 'Invalid dag_run_id'}
 
         dag = current_app.dag_bag.get_dag(dag_id)
 
         if not dag:
-            flash(f'Cannot find DAG: {dag_id}', 'error')
-            return redirect(origin)
+            return {'status': 'error', 'message': f'Cannot find DAG: {dag_id}'}
 
         new_dag_state = set_dag_run_state_to_queued(dag=dag, run_id=dag_run_id, commit=confirmed)
 
         if confirmed:
-            flash('Marked the DagRun as queued.')
-            return redirect(origin)
+            return {'status': 'success', 'message': 'Marked the DagRun as queued.'}
 
         else:
-            details = '\n'.join(str(t) for t in new_dag_state)
-
-            response = self.render_template(
-                'airflow/confirm.html',
-                message="Here's the list of task instances you are about to change",
-                details=details,
-            )
+            details = [str(t) for t in new_dag_state]
 
-            return response
+            return htmlsafe_json_dumps(details, separators=(',', ':'))
 
     @expose('/dagrun_failed', methods=['POST'])
     @auth.has_access(
@@ -2194,8 +2190,7 @@ def dagrun_failed(self):
         dag_id = request.form.get('dag_id')
         dag_run_id = request.form.get('dag_run_id')
         confirmed = request.form.get('confirmed') == 'true'
-        origin = get_safe_url(request.form.get('origin'))
-        return self._mark_dagrun_state_as_failed(dag_id, dag_run_id, confirmed, origin)
+        return self._mark_dagrun_state_as_failed(dag_id, dag_run_id, confirmed)
 
     @expose('/dagrun_success', methods=['POST'])
     @auth.has_access(
@@ -2210,8 +2205,7 @@ def dagrun_success(self):
         dag_id = request.form.get('dag_id')
         dag_run_id = request.form.get('dag_run_id')
         confirmed = request.form.get('confirmed') == 'true'
-        origin = get_safe_url(request.form.get('origin'))
-        return self._mark_dagrun_state_as_success(dag_id, dag_run_id, confirmed, origin)
+        return self._mark_dagrun_state_as_success(dag_id, dag_run_id, confirmed)
 
     @expose('/dagrun_queued', methods=['POST'])
     @auth.has_access(
@@ -2226,8 +2220,7 @@ def dagrun_queued(self):
         dag_id = request.form.get('dag_id')
         dag_run_id = request.form.get('dag_run_id')
         confirmed = request.form.get('confirmed') == 'true'
-        origin = get_safe_url(request.form.get('origin'))
-        return self._mark_dagrun_state_as_queued(dag_id, dag_run_id, confirmed, origin)
+        return self._mark_dagrun_state_as_queued(dag_id, dag_run_id, confirmed)
 
     @expose("/dagrun_details")
     @auth.has_access(
@@ -2333,17 +2326,18 @@ def confirm(self):
         downstream = to_boolean(args.get('downstream'))
         future = to_boolean(args.get('future'))
         past = to_boolean(args.get('past'))
+        origin = origin or url_for('Airflow.index')
 
         dag = current_app.dag_bag.get_dag(dag_id)
         if not dag:
-            flash(f'DAG {dag_id} not found', "error")
-            return redirect(origin or url_for('Airflow.index'))
+            msg = f'DAG {dag_id} not found'
+            return redirect_or_json(origin, msg, status='error')
 
         try:
             task = dag.get_task(task_id)
         except airflow.exceptions.TaskNotFound:
-            flash(f"Task {task_id} not found", "error")
-            return redirect(origin or url_for('Airflow.index'))
+            msg = f"Task {task_id} not found"
+            return redirect_or_json(origin, msg, status='error')
 
         task.dag = dag
 
@@ -2351,13 +2345,13 @@ def confirm(self):
             'success',
             'failed',
         ):
-            flash(f"Invalid state {state}, must be either 'success' or 'failed'", "error")
-            return redirect(origin or url_for('Airflow.index'))
+            msg = f"Invalid state {state}, must be either 'success' or 'failed'"
+            return redirect_or_json(origin, msg, status='error')
 
         latest_execution_date = dag.get_latest_execution_date()
         if not latest_execution_date:
-            flash(f"Cannot mark tasks as {state}, seem that dag {dag_id} has never run", "error")
-            return redirect(origin or url_for('Airflow.index'))
+            msg = f"Cannot mark tasks as {state}, seem that dag {dag_id} has never run"
+            return redirect_or_json(origin, msg, status='error')
 
         from airflow.api.common.mark_tasks import set_state
 
@@ -2372,12 +2366,16 @@ def confirm(self):
             commit=False,
         )
 
+        if request.headers.get('Accept') == 'application/json':
+            details = [str(t) for t in to_be_altered]
+            return htmlsafe_json_dumps(details, separators=(',', ':'))
+
         details = "\n".join(str(t) for t in to_be_altered)
 
         response = self.render_template(
             "airflow/confirm.html",
             endpoint=url_for(f'Airflow.{state}'),
-            message=f"Here's the list of task instances you are about to mark as {state}:",
+            message=f"Task instances you are about to mark as {state}:",
             details=details,
         )
 


Test patch:
diff --git a/airflow/www/static/js/tree/useTreeData.test.jsx b/airflow/www/static/js/tree/api/useTreeData.test.jsx
similarity index 57%
rename from airflow/www/static/js/tree/useTreeData.test.jsx
rename to airflow/www/static/js/tree/api/useTreeData.test.jsx
--- a/airflow/www/static/js/tree/useTreeData.test.jsx
+++ b/airflow/www/static/js/tree/api/useTreeData.test.jsx
@@ -17,9 +17,10 @@
  * under the License.
  */
 import React from 'react';
-import { renderHook, act } from '@testing-library/react-hooks';
+import { renderHook } from '@testing-library/react-hooks';
 import { QueryClient, QueryClientProvider } from 'react-query';
 import useTreeData from './useTreeData';
+import { AutoRefreshProvider } from '../context/autorefresh';
 
 /* global describe, test, expect, jest, beforeAll */
 
@@ -40,17 +41,14 @@ const pendingTreeData = {
   ],
 };
 
-const finalTreeData = {
-  groups: {},
-  dag_runs: [{ ...pendingTreeData.dag_runs[0], state: 'failed' }],
-};
-
-const QueryWrapper = ({ children }) => {
+const Wrapper = ({ children }) => {
   const queryClient = new QueryClient();
   return (
-    <QueryClientProvider client={queryClient}>
-      {children}
-    </QueryClientProvider>
+    <AutoRefreshProvider>
+      <QueryClientProvider client={queryClient}>
+        {children}
+      </QueryClientProvider>
+    </AutoRefreshProvider>
   );
 };
 
@@ -63,68 +61,31 @@ describe('Test useTreeData hook', () => {
   test('data is valid camelcase json', () => {
     global.treeData = JSON.stringify(pendingTreeData);
 
-    const { result } = renderHook(() => useTreeData(), { wrapper: QueryWrapper });
-    const { data, isRefreshOn, onToggleRefresh } = result.current;
+    const { result } = renderHook(() => useTreeData(), { wrapper: Wrapper });
+    const { data } = result.current;
 
     expect(typeof data === 'object').toBe(true);
     expect(data.dagRuns).toBeDefined();
     expect(data.dag_runs).toBeUndefined();
-    expect(isRefreshOn).toBe(false);
-    expect(typeof onToggleRefresh).toBe('function');
-  });
-
-  test('turn on autorefresh for a queued run and then auto-turn off when run fails', async () => {
-    // return a dag run of failed during refresh
-    const mockFetch = jest
-      .spyOn(global, 'fetch')
-      .mockResolvedValue({ json: jest.fn().mockResolvedValue(finalTreeData) });
-
-    global.treeData = JSON.stringify(pendingTreeData);
-    global.autoRefreshInterval = 0.1;
-
-    const { result, waitFor } = renderHook(() => useTreeData(), { wrapper: QueryWrapper });
-
-    expect(result.current.isRefreshOn).toBe(false);
-
-    act(() => {
-      result.current.onToggleRefresh();
-    });
-
-    expect(result.current.isRefreshOn).toBe(true);
-
-    await waitFor(() => expect(mockFetch).toBeCalled());
-
-    await waitFor(() => expect(result.current.isRefreshOn).toBe(false));
-  });
-
-  test('data with a finished state should have refresh off by default', () => {
-    global.treeData = JSON.stringify(finalTreeData);
-
-    const { result } = renderHook(() => useTreeData(), { wrapper: QueryWrapper });
-    const { isRefreshOn } = result.current;
-
-    expect(isRefreshOn).toBe(false);
   });
 
   test('Can handle no treeData', () => {
     global.treeData = null;
 
-    const { result } = renderHook(() => useTreeData(), { wrapper: QueryWrapper });
-    const { data, isRefreshOn } = result.current;
+    const { result } = renderHook(() => useTreeData(), { wrapper: Wrapper });
+    const { data } = result.current;
 
     expect(data.dagRuns).toStrictEqual([]);
     expect(data.groups).toStrictEqual({});
-    expect(isRefreshOn).toBe(false);
   });
 
   test('Can handle empty treeData object', () => {
     global.treeData = {};
 
-    const { result } = renderHook(() => useTreeData(), { wrapper: QueryWrapper });
-    const { data, isRefreshOn } = result.current;
+    const { result } = renderHook(() => useTreeData(), { wrapper: Wrapper });
+    const { data } = result.current;
 
     expect(data.dagRuns).toStrictEqual([]);
     expect(data.groups).toStrictEqual({});
-    expect(isRefreshOn).toBe(false);
   });
 });
diff --git a/airflow/www/static/js/tree/dagRuns/index.test.jsx b/airflow/www/static/js/tree/dagRuns/index.test.jsx
--- a/airflow/www/static/js/tree/dagRuns/index.test.jsx
+++ b/airflow/www/static/js/tree/dagRuns/index.test.jsx
@@ -17,18 +17,20 @@
  * under the License.
  */
 
-/* global describe, test, expect, jest */
+/* global describe, test, expect */
 
 import React from 'react';
 import { render } from '@testing-library/react';
 import { ChakraProvider, Table, Tbody } from '@chakra-ui/react';
-import moment from 'moment';
+import moment from 'moment-timezone';
 import { QueryClient, QueryClientProvider } from 'react-query';
 
 import DagRuns from './index';
+import { ContainerRefProvider } from '../context/containerRef';
+import { SelectionProvider } from '../context/selection';
+import { TimezoneProvider } from '../context/timezone';
+import { AutoRefreshProvider } from '../context/autorefresh';
 
-// Mock modal open function we take from dag.js
-jest.mock('../../dag', () => ({ callModalDag: () => {} }));
 global.moment = moment;
 
 const Wrapper = ({ children }) => {
@@ -37,11 +39,19 @@ const Wrapper = ({ children }) => {
     <React.StrictMode>
       <ChakraProvider>
         <QueryClientProvider client={queryClient}>
-          <Table>
-            <Tbody>
-              {children}
-            </Tbody>
-          </Table>
+          <ContainerRefProvider value={{}}>
+            <TimezoneProvider value={{ timezone: 'UTC' }}>
+              <AutoRefreshProvider value={{ isRefreshOn: false, stopRefresh: () => {} }}>
+                <SelectionProvider value={{ onSelect: () => {}, selected: {} }}>
+                  <Table>
+                    <Tbody>
+                      {children}
+                    </Tbody>
+                  </Table>
+                </SelectionProvider>
+              </AutoRefreshProvider>
+            </TimezoneProvider>
+          </ContainerRefProvider>
         </QueryClientProvider>
       </ChakraProvider>
     </React.StrictMode>
@@ -72,7 +82,6 @@ describe('Test DagRuns', () => {
       endDate: '2021-11-09T00:22:18.607167+00:00',
     },
   ];
-  const mockRef = {};
 
   test('Durations and manual run arrow render correctly, but without any date ticks', () => {
     global.treeData = JSON.stringify({
@@ -80,7 +89,7 @@ describe('Test DagRuns', () => {
       dagRuns,
     });
     const { queryAllByTestId, getByText, queryByText } = render(
-      <DagRuns containerRef={mockRef} />, { wrapper: Wrapper },
+      <DagRuns />, { wrapper: Wrapper },
     );
     expect(queryAllByTestId('run')).toHaveLength(2);
     expect(queryAllByTestId('manual-run')).toHaveLength(1);
@@ -118,7 +127,7 @@ describe('Test DagRuns', () => {
       ],
     });
     const { getByText } = render(
-      <DagRuns containerRef={mockRef} />, { wrapper: Wrapper },
+      <DagRuns />, { wrapper: Wrapper },
     );
     expect(getByText(moment.utc(dagRuns[0].executionDate).format('MMM DD, HH:mm'))).toBeInTheDocument();
   });
@@ -129,7 +138,7 @@ describe('Test DagRuns', () => {
       dagRuns: [],
     };
     const { queryByTestId } = render(
-      <DagRuns containerRef={mockRef} />, { wrapper: Wrapper },
+      <DagRuns />, { wrapper: Wrapper },
     );
     expect(queryByTestId('run')).toBeNull();
   });
@@ -137,7 +146,7 @@ describe('Test DagRuns', () => {
   test('Handles no data correctly', () => {
     global.treeData = {};
     const { queryByTestId } = render(
-      <DagRuns containerRef={mockRef} />, { wrapper: Wrapper },
+      <DagRuns />, { wrapper: Wrapper },
     );
     expect(queryByTestId('run')).toBeNull();
   });
diff --git a/airflow/www/static/js/tree/renderTaskRows.test.jsx b/airflow/www/static/js/tree/renderTaskRows.test.jsx
--- a/airflow/www/static/js/tree/renderTaskRows.test.jsx
+++ b/airflow/www/static/js/tree/renderTaskRows.test.jsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-/* global describe, test, expect, jest */
+/* global describe, test, expect */
 
 import React from 'react';
 import { render, fireEvent } from '@testing-library/react';
@@ -26,12 +26,9 @@ import moment from 'moment';
 import { QueryClient, QueryClientProvider } from 'react-query';
 
 import renderTaskRows from './renderTaskRows';
+import { ContainerRefProvider } from './context/containerRef';
+import { SelectionProvider } from './context/selection';
 
-// Mock modal open function we take from dag.js
-jest.mock('../dag', () => ({
-  callModalDag: () => {},
-  callModal: () => {},
-}));
 global.moment = moment;
 
 const mockTreeData = {
@@ -103,11 +100,15 @@ const Wrapper = ({ children }) => {
     <React.StrictMode>
       <ChakraProvider>
         <QueryClientProvider client={queryClient}>
-          <Table>
-            <Tbody>
-              {children}
-            </Tbody>
-          </Table>
+          <ContainerRefProvider value={{}}>
+            <SelectionProvider value={{ onSelect: () => {}, selected: {} }}>
+              <Table>
+                <Tbody>
+                  {children}
+                </Tbody>
+              </Table>
+            </SelectionProvider>
+          </ContainerRefProvider>
         </QueryClientProvider>
       </ChakraProvider>
     </React.StrictMode>
@@ -117,11 +118,11 @@ const Wrapper = ({ children }) => {
 describe('Test renderTaskRows', () => {
   test('Group defaults to closed but clicking on the name will open a group', () => {
     global.treeData = mockTreeData;
-    const containerRef = {};
     const dagRunIds = mockTreeData.dagRuns.map((dr) => dr.runId);
+    const task = mockTreeData.groups;
 
     const { getByTestId, getByText, getAllByTestId } = render(
-      <>{renderTaskRows({ task: mockTreeData.groups, containerRef, dagRunIds })}</>,
+      <>{renderTaskRows({ task, dagRunIds })}</>,
       { wrapper: Wrapper },
     );
 
@@ -163,12 +164,10 @@ describe('Test renderTaskRows', () => {
       },
       dagRuns: [],
     };
-    const containerRef = {};
+    const task = mockTreeData.groups;
 
     const { queryByTestId, getByText } = render(
-      <>
-        {renderTaskRows({ task: mockTreeData.groups, containerRef, dagRunIds: [] })}
-      </>,
+      <>{renderTaskRows({ task, dagRunIds: [] })}</>,
       { wrapper: Wrapper },
     );
 
diff --git a/tests/www/views/test_views_log.py b/tests/www/views/test_views_log.py
--- a/tests/www/views/test_views_log.py
+++ b/tests/www/views/test_views_log.py
@@ -56,7 +56,12 @@ def backup_modules():
 @pytest.fixture(scope="module")
 def log_app(backup_modules):
     @dont_initialize_flask_app_submodules(
-        skip_all_except=["init_appbuilder", "init_jinja_globals", "init_appbuilder_views"]
+        skip_all_except=[
+            "init_appbuilder",
+            "init_jinja_globals",
+            "init_appbuilder_views",
+            "init_api_connexion",
+        ]
     )
     @conf_vars({('logging', 'logging_config_class'): 'airflow_local_settings.LOGGING_CONFIG'})
     def factory():