Repository: holoviz/holoviews

Issue:
Too many toolbars on grid plots
GridSpace elements have somehow started having a Bokeh toolbar for every single subplot (HoloViews 1.18.3):
https://holoviews.org/reference/containers/bokeh/GridSpace.html
<img width="892" alt="image" src="https://github.com/holoviz/holoviews/assets/1695496/39dfd20e-0676-414d-8577-6028b8ccb4ad">

@maximlt [suggests](https://github.com/holoviz/hvplot/issues/1285#issuecomment-1958783218) that https://github.com/holoviz/holoviews/pull/5873 may be the culprit.





Discussion:

Resolving patch:
diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py
--- a/holoviews/plotting/bokeh/plot.py
+++ b/holoviews/plotting/bokeh/plot.py
@@ -614,8 +614,7 @@ def initialize_plot(self, ranges=None, plots=None):
             sync_legends(plot)
         plot = self._make_axes(plot)
         if hasattr(plot, "toolbar") and self.merge_tools:
-            plot.toolbar = merge_tools(plots)
-
+            plot.toolbar = merge_tools(plots, hide_toolbar=True)
         title = self._get_title_div(self.keys[-1])
         if title:
             plot = Column(title, plot)
diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py
--- a/holoviews/plotting/bokeh/util.py
+++ b/holoviews/plotting/bokeh/util.py
@@ -384,7 +384,7 @@ def compute_layout_properties(
     return aspect_info, dimension_info
 
 
-def merge_tools(plot_grid, disambiguation_properties=None):
+def merge_tools(plot_grid, *, disambiguation_properties=None, hide_toolbar=False):
     """
     Merges tools defined on a grid of plots into a single toolbar.
     All tools of the same type are merged unless they define one
@@ -397,6 +397,8 @@ def merge_tools(plot_grid, disambiguation_properties=None):
             if isinstance(item, LayoutDOM):
                 for p in item.select(dict(type=Plot)):
                     tools.extend(p.toolbar.tools)
+            if hide_toolbar and hasattr(item, 'toolbar_location'):
+                item.toolbar_location = None
             if isinstance(item, GridPlot):
                 item.toolbar_location = None
 


Test patch:
diff --git a/holoviews/tests/conftest.py b/holoviews/tests/conftest.py
--- a/holoviews/tests/conftest.py
+++ b/holoviews/tests/conftest.py
@@ -2,14 +2,18 @@
 import sys
 from collections.abc import Callable
 
+import panel as pn
 import pytest
-from panel.tests.conftest import (  # noqa
+from panel.tests.conftest import (  # noqa: F401
     optional_markers,
     port,
     pytest_addoption,
     pytest_configure,
     server_cleanup,
 )
+from panel.tests.util import serve_and_wait
+
+import holoviews as hv
 
 
 def pytest_collection_modifyitems(config, items):
@@ -32,13 +36,15 @@ def pytest_collection_modifyitems(config, items):
 
 with contextlib.suppress(ImportError):
     import matplotlib as mpl
-    mpl.use('agg')
+
+    mpl.use("agg")
 
 
 with contextlib.suppress(Exception):
     # From Dask 2023.7,1 they now automatic convert strings
     # https://docs.dask.org/en/stable/changelog.html#v2023-7-1
     import dask
+
     dask.config.set({"dataframe.convert-string": False})
 
 
@@ -49,40 +55,38 @@ def ibis_sqlite_backend():
     except ImportError:
         yield None
     else:
-        ibis.set_backend('sqlite')
+        ibis.set_backend("sqlite")
         yield
         ibis.set_backend(None)
 
 
 @pytest.fixture
 def bokeh_backend():
-    import holoviews as hv
-    hv.renderer('bokeh')
+    hv.renderer("bokeh")
     prev_backend = hv.Store.current_backend
-    hv.Store.current_backend = 'bokeh'
+    hv.Store.current_backend = "bokeh"
     yield
     hv.Store.current_backend = prev_backend
 
 
 @pytest.fixture
 def mpl_backend():
-    import holoviews as hv
-    hv.renderer('matplotlib')
+    hv.renderer("matplotlib")
     prev_backend = hv.Store.current_backend
-    hv.Store.current_backend = 'matplotlib'
+    hv.Store.current_backend = "matplotlib"
     yield
     hv.Store.current_backend = prev_backend
 
 
 @pytest.fixture
 def plotly_backend():
-    import holoviews as hv
-    hv.renderer('plotly')
+    hv.renderer("plotly")
     prev_backend = hv.Store.current_backend
-    hv.Store.current_backend = 'plotly'
+    hv.Store.current_backend = "plotly"
     yield
     hv.Store.current_backend = prev_backend
 
+
 @pytest.fixture
 def unimport(monkeypatch: pytest.MonkeyPatch) -> Callable[[str], None]:
     """
@@ -98,3 +102,13 @@ def unimport_module(modname: str) -> None:
         monkeypatch.setattr(sys, "path", [])
 
     return unimport_module
+
+
+@pytest.fixture
+def serve_hv(page, port):  # noqa: F811
+    def serve_and_return_page(hv_obj):
+        serve_and_wait(pn.pane.HoloViews(hv_obj), port=port)
+        page.goto(f"http://localhost:{port}")
+        return page
+
+    return serve_and_return_page
diff --git a/holoviews/tests/ui/__init__.py b/holoviews/tests/ui/__init__.py
new file mode 100644
--- /dev/null
+++ b/holoviews/tests/ui/__init__.py
@@ -0,0 +1,8 @@
+try:
+    from playwright.sync_api import expect
+except ImportError:
+    expect = None
+
+from panel.tests.util import wait_until
+
+__all__ = ("expect", "wait_until")
diff --git a/holoviews/tests/ui/bokeh/__init__.py b/holoviews/tests/ui/bokeh/__init__.py
new file mode 100644
diff --git a/holoviews/tests/ui/bokeh/test_callback.py b/holoviews/tests/ui/bokeh/test_callback.py
--- a/holoviews/tests/ui/bokeh/test_callback.py
+++ b/holoviews/tests/ui/bokeh/test_callback.py
@@ -1,23 +1,17 @@
 import time
 
 import numpy as np
-import pytest
-
-try:
-    from playwright.sync_api import expect
-except ImportError:
-    pytestmark = pytest.mark.skip('playwright not available')
-
-pytestmark = pytest.mark.ui
-
 import panel as pn
-from panel.pane.holoviews import HoloViews
-from panel.tests.util import serve_and_wait, wait_until
+import pytest
 
 import holoviews as hv
 from holoviews import Curve, DynamicMap, Scatter
 from holoviews.streams import BoundsX, BoundsXY, BoundsY, Lasso, RangeXY
 
+from .. import expect, wait_until
+
+pytestmark = pytest.mark.ui
+
 
 @pytest.mark.usefixtures("bokeh_backend")
 @pytest.mark.parametrize(
@@ -28,18 +22,14 @@
         (BoundsY, slice(1, None, 2), "boundsy"),
     ],
 )
-def test_box_select(page, port, BoundsTool, bound_slice, bound_attr):
+def test_box_select(serve_hv, BoundsTool, bound_slice, bound_attr):
     hv_scatter = Scatter([1, 2, 3]).opts(
         tools=['box_select'], active_tools=['box_select']
     )
 
     bounds = BoundsTool(source=hv_scatter)
 
-    pn_scatter = HoloViews(hv_scatter)
-
-    serve_and_wait(pn_scatter, port=port)
-    page.goto(f"http://localhost:{port}")
-
+    page = serve_hv(hv_scatter)
     hv_plot = page.locator('.bk-events')
 
     expect(hv_plot).to_have_count(1)
@@ -57,18 +47,14 @@ def test_box_select(page, port, BoundsTool, bound_slice, bound_attr):
 
 
 @pytest.mark.usefixtures("bokeh_backend")
-def test_lasso_select(page, port):
+def test_lasso_select(serve_hv):
     hv_scatter = Scatter([1, 2, 3]).opts(
         tools=['lasso_select'], active_tools=['lasso_select']
     )
 
     lasso = Lasso(source=hv_scatter)
 
-    pn_scatter = HoloViews(hv_scatter)
-
-    serve_and_wait(pn_scatter, port=port)
-    page.goto(f"http://localhost:{port}")
-
+    page = serve_hv(hv_scatter)
     hv_plot = page.locator('.bk-events')
 
     expect(hv_plot).to_have_count(1)
@@ -100,16 +86,12 @@ def test_lasso_select(page, port):
     np.testing.assert_almost_equal(lasso.geometry, expected_array)
 
 @pytest.mark.usefixtures("bokeh_backend")
-def test_rangexy(page, port):
+def test_rangexy(serve_hv):
     hv_scatter = Scatter([1, 2, 3]).opts(active_tools=['box_zoom'])
 
     rangexy = RangeXY(source=hv_scatter)
 
-    pn_scatter = HoloViews(hv_scatter)
-
-    serve_and_wait(pn_scatter, port=port)
-    page.goto(f"http://localhost:{port}")
-
+    page = serve_hv(hv_scatter)
     hv_plot = page.locator('.bk-events')
 
     expect(hv_plot).to_have_count(1)
@@ -127,7 +109,7 @@ def test_rangexy(page, port):
     wait_until(lambda: rangexy.x_range == expected_xrange and rangexy.y_range == expected_yrange, page)
 
 @pytest.mark.usefixtures("bokeh_backend")
-def test_multi_axis_rangexy(page, port):
+def test_multi_axis_rangexy(serve_hv):
     c1 = Curve(np.arange(100).cumsum(), vdims='y')
     c2 = Curve(-np.arange(100).cumsum(), vdims='y2')
     s1 = RangeXY(source=c1)
@@ -135,11 +117,7 @@ def test_multi_axis_rangexy(page, port):
 
     overlay = (c1 * c2).opts(multi_y=True)
 
-    pn_scatter = HoloViews(overlay)
-
-    serve_and_wait(pn_scatter, port=port)
-    page.goto(f"http://localhost:{port}")
-
+    page = serve_hv(overlay)
     hv_plot = page.locator('.bk-events')
 
     expect(hv_plot).to_have_count(1)
@@ -163,7 +141,7 @@ def test_multi_axis_rangexy(page, port):
 
 
 @pytest.mark.usefixtures("bokeh_backend")
-def test_bind_trigger(page, port):
+def test_bind_trigger(serve_hv):
     # Regression test for https://github.com/holoviz/holoviews/issues/6013
 
     BOUND_COUNT, RANGE_COUNT = [0], [0]
@@ -179,11 +157,8 @@ def range_function(x_range, y_range):
 
     range_dmap = DynamicMap(range_function, streams=[hv.streams.RangeXY()])
     bind_dmap = DynamicMap(pn.bind(bound_function))
-    widget = pn.pane.HoloViews(bind_dmap * range_dmap)
-
-    serve_and_wait(widget, port=port)
-    page.goto(f"http://localhost:{port}")
 
+    page = serve_hv(bind_dmap * range_dmap)
     hv_plot = page.locator('.bk-events')
     expect(hv_plot).to_have_count(1)
 
diff --git a/holoviews/tests/ui/bokeh/test_layout.py b/holoviews/tests/ui/bokeh/test_layout.py
new file mode 100644
--- /dev/null
+++ b/holoviews/tests/ui/bokeh/test_layout.py
@@ -0,0 +1,24 @@
+import numpy as np
+import pytest
+
+import holoviews as hv
+
+from .. import expect
+
+pytestmark = pytest.mark.ui
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+def test_gridspace_toolbar(serve_hv):
+    def sine_curve(phase, freq):
+        xvals = [0.1 * i for i in range(100)]
+        return hv.Curve((xvals, [np.sin(phase + freq * x) for x in xvals]))
+
+    phases = [0, np.pi / 2, np.pi, 3 * np.pi / 2]
+    frequencies = [0.5, 0.75, 1.0, 1.25]
+    curve_dict_2D = {(p, f): sine_curve(p, f) for p in phases for f in frequencies}
+    gridspace = hv.GridSpace(curve_dict_2D, kdims=["phase", "frequency"])
+
+    page = serve_hv(gridspace)
+    bokeh_logo = page.locator(".bk-logo")
+    expect(bokeh_logo).to_have_count(1)