Repository: ESMValGroup/ESMValCore

Issue:
Dynamic HTML output for monitoring
The ESMValTool HTML output file, together with monitoring recipes, is being used to monitor model runs. However, the way data is presented makes this use case cumbersome, so we would like to make some improvements.

### Implemented Features
#### Tab selection of diagnostics
Now it's possible to to view only one diagnostic at a time. The selection is done as tab on the top of the screen, for ease of accessibility.

<details><summary>Screenshots</summary>
<p>

![image](https://github.com/ESMValGroup/ESMValCore/assets/20075170/a675ea7f-c442-4769-bcb0-f2090f4385eb)
![image](https://github.com/ESMValGroup/ESMValCore/assets/20075170/9f8da324-cb4b-45b8-90b8-2bf0061d08f3)


</p>
</details> 

#### Hiding by default "Data Files" section
The "Data Files" section provides links to the sources used to generate the plot. As this information is not necessary when examining the plots, it is now hidden by default. It can be shown again by pressing a button.

<details><summary>Screenshots</summary>
<p>

![image](https://github.com/ESMValGroup/ESMValCore/assets/20075170/a4c24ed5-e4f9-409a-9d7c-e4d56e84b39b)

![image](https://github.com/ESMValGroup/ESMValCore/assets/20075170/5e480c60-f561-4423-906f-fd915c86c757)


</p>
</details> 

#### Plot filtering
It is possible to show only a subset of plots, based on different properties. These properties have to be available on the provenance record. The current supported provenance keys are:
* plot_type, plot_types
* long_names: Long names of variables used
* realms

<details><summary>Screenshots</summary>
<p>
![image](https://github.com/ESMValGroup/ESMValCore/assets/20075170/eadd89fd-7f80-4861-8e0b-3690604b473d)
![image](https://github.com/ESMValGroup/ESMValCore/assets/20075170/999cf6ef-5547-41f8-a784-080da1b0ee9b)


</p>
</details> 

Furthermore, it is possible to hide diagnostics when all their plots are hidden. This behavior can be disabled with a checkmark.

### Live demo
* recipe_monitor.yml:
  * [new version](https://swift.dkrz.de/v1/dkrz_489a8873-df8c-4bed-a8a9-4c14304df6cf/esmvaltool_html_output/new_v2.0/recipe_monitor/index.html)
  * [current look](https://swift.dkrz.de/v1/dkrz_489a8873-df8c-4bed-a8a9-4c14304df6cf/esmvaltool_html_output/current/recipe_monitor/index.html)
* recipe_monitor_with_refs.yml
  * [new version](https://swift.dkrz.de/v1/dkrz_489a8873-df8c-4bed-a8a9-4c14304df6cf/esmvaltool_html_output/new_v2.0/recipe_monitor_with_refs/index.html)
  * [current look](https://swift.dkrz.de/v1/dkrz_489a8873-df8c-4bed-a8a9-4c14304df6cf/esmvaltool_html_output/current/recipe_monitor_with_refs/index.html)

### Open questions
As the HTML file was created for a different purpose we don't know if it could be modified. That issue could be avoided by making this new HTML format an option that could be selected on the recipe.

I have a few questions for the community:
1. Could it be possible to modify the current HTML output file, or should we make that an option?
  a. If we make it an option, where should it go in the recipe? I assume it should go in the "documentation" section, but that section is currently used only for metadata and adding a configuration option there seems a bit out of place.
2. A common request is to be able to open the website directly from the run. For example, the output could include a link that works with port forwarding, or upload the results to a service like DKRZ's [Swift Object Store](https://docs.dkrz.de/doc/datastorage/swift/index.html). However, both these options require additional configuration, and the latter is also site-exclusive. Is there an easier way to achieve this?

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/ESMValGroup/ESMValCore/pull/2062?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup) Report
> Merging [#2062](https://app.codecov.io/gh/ESMValGroup/ESMValCore/pull/2062?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup) (b429a30) into [main](https://app.codecov.io/gh/ESMValGroup/ESMValCore/commit/64f7c0f52f6182cd95a893f6c5522d5d841d270c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ESMValGroup) (64f7c0f) will **not change** coverage.
> The diff coverage is `n/a`.

```diff
@@           Coverage Diff           @@
##             main    #2062   +/-   ##
=======================================
  Coverage   92.79%   92.79%           
=======================================
  Files         236      236           
  Lines       12484    12484           
=======================================
  Hits        11585    11585           
  Misses        899      899           
```




schlunma: Thanks @bsolino, I am looking forward to a nicer website!

Would it maybe be possible to provide screenshots or (better) a publicly available example website on our bot machine for this? This would make it much easier to test this. Thanks!
bsolino: I have started updating the output to use Bootstrap. I have not managed to completely eliminate the need for custom scripts, but Bootstrap allowed me to add the functionality with some simple jQuery scripts. This could be done with plain JavaScript as well.

I'm struggling with writing the Filter, as it seems it's not a functionality that Bootstrap has, and the closest I found uses a proprietary library: https://mdbootstrap.com/docs/standard/plugins/filters/ I assume that I will have to implement it by using only our own scripts.

@ESMValGroup/technical-lead-development-team:
* Do you have any strong opinion on using jQuery over plain JavaScript?
* Do you have any experience with Bootstrap that could help me with the filter?
bouweandela: > Do you have any experience with Bootstrap that could help me with the filter?

There is a brief explanation [here](https://www.w3schools.com/bootstrap/bootstrap_filters.asp), did you find that already?

I believe @zklaus also mentioned an alternative to bootstrap (I can't recall what it was at the moment), did you have a look at that too?
bsolino: > > Do you have any experience with Bootstrap that could help me with the filter?
> 
> There is a brief explanation [here](https://www.w3schools.com/bootstrap/bootstrap_filters.asp), did you find that already?

Thanks! I've tried to do that, but it's still custom jQuery. It seems there is no way to avoid writing our own logic, especially when we are trying to do more things with it.

> 
> I believe @zklaus also mentioned an alternative to bootstrap (I can't recall what it was at the moment), did you have a look at that too?

I have looked also at React and Node.js, but I wasn't happy that they interacted strangely with Bootstrap and that they also involved writing our own components. I really like Bootstrap, it provided a lot of utility just out of the box and I don't think I want to remove it. There are libraries that provide compatibility between Bootstrap and other frameworks ([e.g. with React)](https://react-bootstrap.github.io/), but it seems to add complexity for little gain :(

I'm just sad that the library I mentioned earlier ([MDBootstrap](https://mdbootstrap.com/)) doesn't work for our purposes, because the [Filter example](https://mdbootstrap.com/docs/standard/plugins/filters/) is almost exactly our use case. However, I believe it would still require to add our logic, to do things like deactivate the tabs for the diagnostics that are empty after filtering, for example.
bsolino: The website has now been totally ported to Bootstrap for visuals and basic functionality, and with jQuery instead of JavaScript to improve compatibility across browsers. New screenshots and live demo are availables on the linked issue. Direct links to live demos:

* [recipe_monitor.yml](https://swift.dkrz.de/v1/dkrz_489a8873-df8c-4bed-a8a9-4c14304df6cf/esmvaltool_html_output/new_v2.0/recipe_monitor/index.html)
* [recipe_monitor_with_refs.yml](https://swift.dkrz.de/v1/dkrz_489a8873-df8c-4bed-a8a9-4c14304df6cf/esmvaltool_html_output/new_v2.0/recipe_monitor_with_refs/index.html)
    
 For comparison:

* Current version, as it is implemented in ESMValTool:
    * [recipe_monitor.yml](https://swift.dkrz.de/v1/dkrz_489a8873-df8c-4bed-a8a9-4c14304df6cf/esmvaltool_html_output/current/recipe_monitor/index.html)
    * [recipe_monitor_with_refs.yml](https://swift.dkrz.de/v1/dkrz_489a8873-df8c-4bed-a8a9-4c14304df6cf/esmvaltool_html_output/current/recipe_monitor_with_refs/index.html)
*  Previous update, before Bootstrap and jQuery:
    * [recipe_monitor.yml](https://swift.dkrz.de/v1/dkrz_489a8873-df8c-4bed-a8a9-4c14304df6cf/esmvaltool_html_output/new_v1.0/recipe_monitor/index.html)
    * [recipe_monitor_with_refs.yml](https://swift.dkrz.de/v1/dkrz_489a8873-df8c-4bed-a8a9-4c14304df6cf/esmvaltool_html_output/new_v1.0/recipe_monitor_with_refs/index.html)

Patch:
diff --git a/esmvalcore/experimental/recipe_output.py b/esmvalcore/experimental/recipe_output.py
index 69f63765a..9765d15c7 100644
--- a/esmvalcore/experimental/recipe_output.py
+++ b/esmvalcore/experimental/recipe_output.py
@@ -2,7 +2,7 @@
 import base64
 import logging
 import os.path
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
 from pathlib import Path
 from typing import Optional, Tuple, Type
 
@@ -123,6 +123,13 @@ class RecipeOutput(Mapping):
         The session used to run the recipe.
     """
 
+    FILTER_ATTRS: list = [
+        "realms",
+        "plot_type",  # Used by several diagnostics
+        "plot_types",
+        "long_names",
+    ]
+
     def __init__(self, task_output: dict, session=None, info=None):
         self._raw_task_output = task_output
         self._task_output = {}
@@ -141,6 +148,7 @@ class RecipeOutput(Mapping):
             diagnostics[name].append(task)
 
         # Create diagnostic output
+        filters: dict = {}
         for name, tasks in diagnostics.items():
             diagnostic_info = info.data['diagnostics'][name]
             self.diagnostics[name] = DiagnosticOutput(
@@ -150,6 +158,36 @@ class RecipeOutput(Mapping):
                 description=diagnostic_info.get('description'),
             )
 
+            # Add data to filters
+            for task in tasks:
+                for file in task.files:
+                    RecipeOutput._add_to_filters(filters, file.attributes)
+
+        # Sort at the end because sets are unordered
+        self.filters = RecipeOutput._sort_filters(filters)
+
+    @classmethod
+    def _add_to_filters(cls, filters, attributes):
+        """Add valid values to the HTML output filters."""
+        for attr in RecipeOutput.FILTER_ATTRS:
+            if attr not in attributes:
+                continue
+            values = attributes[attr]
+            # `set()` to avoid duplicates
+            attr_list = filters.get(attr, set())
+            if (isinstance(values, str) or not isinstance(values, Sequence)):
+                attr_list.add(values)
+            else:
+                attr_list.update(values)
+            filters[attr] = attr_list
+
+    @classmethod
+    def _sort_filters(cls, filters):
+        """Sort the HTML output filters."""
+        for _filter, _attrs in filters.items():
+            filters[_filter] = sorted(_attrs)
+        return filters
+
     def __repr__(self):
         """Return canonical string representation."""
         string = '\n'.join(repr(item) for item in self._task_output.values())
@@ -218,6 +256,7 @@ class RecipeOutput(Mapping):
             diagnostics=self.diagnostics.values(),
             session=self.session,
             info=self.info,
+            filters=self.filters,
             relpath=os.path.relpath,
         )
 
diff --git a/esmvalcore/experimental/templates/RecipeOutput.j2 b/esmvalcore/experimental/templates/RecipeOutput.j2
index c502c39a3..cde84362f 100644
--- a/esmvalcore/experimental/templates/RecipeOutput.j2
+++ b/esmvalcore/experimental/templates/RecipeOutput.j2
@@ -1,12 +1,72 @@
+
+<!-- Tab links -->
+<ul class="nav nav-tabs sticky-top bg-light" id="tabDiagnostics" role="tablist">
+  <li class="nav-item">
+    <!-- Filter -->
+    <div class="dropdown" style="position: static">
+      <button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
+        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-filter" viewBox="0 0 16 16">
+          <path d="M6 10.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5zm-2-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm-2-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/>
+        </svg>
+        Filters
+      </button>
+      <div class="dropdown-menu w-100">
+        <div class="container div_filter">
+          <div class="row justify-content-center">
+            {% for filter_name, filter_values in filters.items() %}
+            {% set filter_loop = loop %}
+            <div class="col-xl-3 col-lg-4 col-sm-6 col-12 filter_category" id="filter_{{ filter_name|replace(' ', '_') }}">
+                <h4>{{ filter_name|replace('_', ' ')|title }}</h4>
+                {% for value in filter_values %}
+                <div class="form-check">
+                  <input class="form-check-input filter_cb" type="checkbox" value="" id="cb_{{ filter_loop.index }}_{{ loop.index }}" rel="f_{{ filter_name|replace(' ', '_') }}_{{ value|replace(' ', '_') }}">
+                  <label class="form-check-label" for="cb_{{ filter_loop.index }}_{{ loop.index }}">
+                    {{ value|replace('_', ' ')|title }}
+                  </label>
+                </div>
+                {% endfor %}
+            </div>
+            {% endfor %}
+          </div>
+          <div class="d-flex justify-content-center align-items-center gap-3">
+            <div class="d-inline-block">
+              <button class="btn btn-primary" id="b_deleteFilters" disabled>Delete Filters</button>
+            </div>
+            <div class="form-check form-switch d-inline-block">
+              <input class="form-check-input" type="checkbox" value="" id="cb_hideEmptyDiagnostics" rel="" checked>
+              <label class="form-check-label" for="cb_hideEmptyDiagnostics">
+                Hide empty diagnostics
+              </label>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </li>
+  <li class="nav-item" role="presentation">
+    <button class="nav-link active diagnostics-tab" id="tabAll" data-bs-toggle="tab" data-bs-target="#" type="button" role="tab" aria-controls="" aria-selected="true">All</button>
+  </li>
+  {% for diagnostic in diagnostics %}
+    <li class="nav-item" role="presentation">
+      <button class="nav-link diagnostics-tab" id="tab_{{ loop.index }}" data-bs-toggle="tab" data-bs-target="#tabPane_{{ loop.index }}" type="button" role="tab" aria-controls="tabPane_{{ loop.index }}" aria-selected="true">{{ diagnostic.title }}</button>
+    </li>
+  {% endfor %}
+</ul>
+
+<div class="tab-content" id="tabContentDiagnostics">
 {% for diagnostic in diagnostics %}
 
-  <h2>{{ diagnostic.title }}</h2>
-  <p>{{ diagnostic.description }}</p>
+  <div id="tabPane_{{ loop.index }}" class="tab-pane show active diagnostics-tab-pane" role="tabpanel" aria-labelledby="tab_{{ loop.index }}">
+    <h2>{{ diagnostic.title }}</h2>
+    <p>{{ diagnostic.description }}</p>
 
-  {% for task in diagnostic.task_output %}
+    {% set diagnostic_loop = loop %}
+    {% for task in diagnostic.task_output %}
 
-    {% include 'TaskOutput.j2' %}
+      {% include 'TaskOutput.j2' %}
 
-  {% endfor %}
+    {% endfor %}
+  </div>
 
 {% endfor %}
+</div>
diff --git a/esmvalcore/experimental/templates/TaskOutput.j2 b/esmvalcore/experimental/templates/TaskOutput.j2
index 48d259ea7..43753418b 100644
--- a/esmvalcore/experimental/templates/TaskOutput.j2
+++ b/esmvalcore/experimental/templates/TaskOutput.j2
@@ -2,11 +2,23 @@
 
 {% for file in task.image_files %}
 
-    <figure>
+<div class="div_figure d-inline-flex
+{% for filter_name in filters.keys() %}
+    {% if filter_name in file.attributes %}
+        {% set attribute = file.attributes[filter_name] %}
+        {% if attribute is string or not (attribute is iterable) %}
+            f_{{ filter_name|replace(' ', '_') }}_{{ attribute | replace(' ', '_') }}
+        {% else %}
+            {% for attr in attribute %} f_{{ filter_name|replace(' ', '_') }}_{{ attr | replace(' ', '_') }} {% endfor %}
+        {% endif %}
+    {% endif %}
+{% endfor %}
+">
+    <figure class="figure">
         <a href='{{ relpath(file.path, session.session_dir) }}'>
-        <img src='{{ relpath(file.path, session.session_dir) }}' alt='{{ file.caption }}'/>
+        <img class="figure-img img-fluid" src='{{ relpath(file.path, session.session_dir) }}' alt='{{ file.caption }}'/>
         </a>
-        <figcaption>
+        <figcaption class="figure-caption">
             {{ file.caption }}
             <br>
             <br>
@@ -16,21 +28,28 @@
             <a href='{{ relpath(file.provenance_xml_file, session.session_dir) }}'>provenance</a>
         </figcaption>
     </figure>
+</div>
 
 {% endfor %}
 
-<h4>Data files</h4>
+{% if task.data_files|length > 0 %}
+<h4>Data files <button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#df_{{ diagnostic_loop.index }}_{{ loop.index }}" aria-expanded="false" aria-controls="df_{{ diagnostic_loop.index }}_{{ loop.index }}">Show/Hide</button></h4>
 
-<ul>
-{% for file in task.data_files %}
+<div id="df_{{ diagnostic_loop.index }}_{{ loop.index }}" class="collapse">
+    <div class="card card-body">
+        <ul>
+        {% for file in task.data_files %}
 
-    <li>
-        {{ file.caption }} |
-        <a href='{{ relpath(file.path, session.session_dir) }}'>download</a> |
-        <a href='{{ relpath(file.citation_file, session.session_dir) }}'>references</a> |
-        <a href='{{ relpath(file.data_citation_file, session.session_dir) }}'>extra data citation</a> |
-        <a href='{{ relpath(file.provenance_xml_file, session.session_dir) }}'>provenance</a>
-    </li>
+            <li>
+                {{ file.caption }} |
+                <a href='{{ relpath(file.path, session.session_dir) }}'>download</a> |
+                <a href='{{ relpath(file.citation_file, session.session_dir) }}'>references</a> |
+                <a href='{{ relpath(file.data_citation_file, session.session_dir) }}'>extra data citation</a> |
+                <a href='{{ relpath(file.provenance_xml_file, session.session_dir) }}'>provenance</a>
+            </li>
 
-{% endfor %}
-</ul>
+        {% endfor %}
+        </ul>
+    </div>
+</div>
+{% endif %}
diff --git a/esmvalcore/experimental/templates/head.j2 b/esmvalcore/experimental/templates/head.j2
index e6ec19d5e..0306620f8 100644
--- a/esmvalcore/experimental/templates/head.j2
+++ b/esmvalcore/experimental/templates/head.j2
@@ -2,6 +2,9 @@
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>{{ title }}</title>
+  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9" crossorigin="anonymous">
+  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js"></script>
+  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-HwwvtgBNo3bZJJLYd8oVXjrBZt8cqVSpeBNS5n7C8IVInixGAoxmnlMuBnhbgrkm" crossorigin="anonymous"></script>
   <style>
     html {
       font-size: medium;
diff --git a/esmvalcore/experimental/templates/recipe_output_page.j2 b/esmvalcore/experimental/templates/recipe_output_page.j2
index 10dc041ca..a970409e4 100644
--- a/esmvalcore/experimental/templates/recipe_output_page.j2
+++ b/esmvalcore/experimental/templates/recipe_output_page.j2
@@ -7,20 +7,34 @@
 
   <body>
 
+    <div class="text-center">
+      <figure class="figure">
+        <img
+          class="figure-img img-fluid"
+          src='https://raw.githubusercontent.com/ESMValGroup/ESMValTool/main/doc/sphinx/source/figures/ESMValTool-logo-2.png'
+          alt='ESMValTool logo.'
+        />
+      </figure>
+    </div>
+
     {% include 'RecipeInfo.j2' %}
 
     {% include 'RecipeOutput.j2' %}
 
-  </body>
+    <h2>Files</h2>
 
-  <h2>Files</h2>
+    <p>
+      <a href='{{ session.relative_main_log }}'>{{ session.main_log.name }}</a> |
+      <a href='{{ session.relative_main_log_debug }}'>{{ session.main_log_debug.name }}</a> |
+      <a href='{{ session.relative_run_dir / info.filename }}'>{{ info.filename }}</a> |
+      <a href='{{ session.relative_plot_dir }}'>figures</a> |
+      <a href='{{ session.relative_work_dir }}'>data</a>
+    </p>
 
-  <p>
-    <a href='{{ session.relative_main_log }}'>{{ session.main_log.name }}</a> |
-    <a href='{{ session.relative_main_log_debug }}'>{{ session.main_log_debug.name }}</a> |
-    <a href='{{ session.relative_run_dir / info.filename }}'>{{ info.filename }}</a> |
-    <a href='{{ session.relative_plot_dir }}'>figures</a> |
-    <a href='{{ session.relative_work_dir }}'>data</a>
-  </p>
+  <script>
+    {% include 'scripts.js' %}
+  </script>
+
+  </body>
 
 </html>
diff --git a/esmvalcore/experimental/templates/scripts.js b/esmvalcore/experimental/templates/scripts.js
new file mode 100644
index 000000000..d2fa981c0
--- /dev/null
+++ b/esmvalcore/experimental/templates/scripts.js
@@ -0,0 +1,122 @@
+function filterFigures(){
+    /**
+     * Update visibility of filtered figures.
+     */
+    let allFigures = $(".div_figure");
+    let selectedFigures = allFigures;
+    $(".filter_category").each(function() {
+        let selection = $(this).find(":checked").map(function() {
+            // Returns the figures that the checkbox relates to.
+            return $("."+$(this).attr("rel")).get();
+        });
+        if (selection.length !== 0){
+            selectedFigures = selectedFigures.filter(selection);
+        }
+    });
+    selectedFigures.addClass("selected") // affects the div
+    .find("figure").show(); // affects figure inside the div
+    allFigures.not(selectedFigures).removeClass("selected") // affects the div
+    .find("figure").hide(); // affects figure inside the div
+}
+
+function filterTabs(){
+    /**
+     * Disable tab buttons for empty diagnostics and
+     * mark empty tabPanes.
+     */
+    $(".diagnostics-tab").not("#tabAll").each(function() {
+        let tabPane = $($(this).attr("data-bs-target"));
+        if (tabPane.find(".div_figure.selected").length === 0){
+            $(this).addClass("disabled");
+            tabPane.addClass("filtered");
+        } else {
+            $(this).removeClass("disabled");
+            tabPane.removeClass("filtered");
+        }
+
+        // If the active tab is disabled, change to "All"
+        if($(".diagnostics-tab.active").hasClass("disabled")){
+            $("#tabAll").click();
+        }
+    });
+}
+
+function hideEmptyTabPanes(){
+    /**
+     * Hide empty tab panes. It's separated from "filterTabs()"
+     * to reuse on the "Hide empty diagnostics" checkbox
+     */
+    if($("#tabAll").hasClass("active")){
+        let panes = $(".diagnostics-tab-pane");
+        panes.addClass("active").addClass("show");
+        if ($("#cb_hideEmptyDiagnostics").prop("checked")){
+            panes.filter(".filtered").removeClass("active").removeClass("show");
+        }
+    }
+}
+
+function applyFilters(){
+    /**
+     * Updates visibility according to filters.
+     */
+    filterFigures();
+    filterTabs();
+    hideEmptyTabPanes();
+}
+
+// Set up events with jQuery
+// Specific events are defined as anonymous functions
+$(document).ready(function() {
+
+    $("#tabAll").on("click", function() {
+        /**
+         * Functionality for tab "All", as it is not supported
+         * by Bootstrap.
+         */
+
+        // Both activate this tab
+        $(this).addClass("active")
+        // and deactivate other tabs
+        .parent("li").siblings().find("button").removeClass("active");
+
+        // Show all non-filtered tab panes
+        let tabPanes = $(".diagnostics-tab-pane");
+        if ($("#cb_hideEmptyDiagnostics").prop("checked")){
+            tabPanes = tabPanes.not(".filtered");
+        }
+        tabPanes.addClass("active").addClass("show");
+    });
+
+    $(".diagnostics-tab").not("#tabAll").on("click", function() {
+        /**
+         * Upgrades Bootstrap tab functionality to deactivate
+         * tab "All" by hiding all non-selected panes, as
+         * Bootstrap hides only one pane.
+         */
+        $(".diagnostics-tab-pane").not($(this).attr("data-bs-target"))
+        .removeClass("active").removeClass("show");
+    });
+
+    // Checkbox "Hide empty diagnostics"
+    $("#cb_hideEmptyDiagnostics").on("click", hideEmptyTabPanes);
+
+    $("#b_deleteFilters").on("click", function(){
+        /**
+         * Unchecks all filters and disables "Delete filters" button.
+         */
+        $(".filter_cb").prop("checked", false);
+        applyFilters();
+        $(this).prop("disabled", true);
+    });
+
+    $(".filter_cb").on("click", function(){
+        /**
+         * Update visibility of figures and panes when filters
+         * are applied, and set up disable filters button.
+         */
+        applyFilters();
+
+        let areFiltersClear = $(".filter_cb:checked").length === 0;
+        $("#b_deleteFilters").prop("disabled", areFiltersClear);
+    });
+});


Test patch:
diff --git a/tests/unit/experimental/test_recipe_output.py b/tests/unit/experimental/test_recipe_output.py
index 2c612130a..756cfaad7 100644
--- a/tests/unit/experimental/test_recipe_output.py
+++ b/tests/unit/experimental/test_recipe_output.py
@@ -28,3 +28,55 @@ def test_diagnostic_output_repr(mocker):
     assert repr(diagnostic) == text
     for task in tasks:
         task.__str__.assert_called_once()
+
+
+def test_recipe_output_add_to_filters():
+    """Coverage test for `RecipeOutput._add_to_filters`."""
+
+    filters = {}
+    valid_attr = recipe_output.RecipeOutput.FILTER_ATTRS[0]
+
+    recipe_output.RecipeOutput._add_to_filters(filters,
+                                               {valid_attr: "single value"})
+    recipe_output.RecipeOutput._add_to_filters(
+        filters, {valid_attr: ["list value 1", "repeated list value"]})
+    recipe_output.RecipeOutput._add_to_filters(
+        filters, {valid_attr: ["list value 2", "repeated list value"]})
+
+    assert len(filters) == 1
+    assert valid_attr in filters
+    assert len(filters[valid_attr]) == 4
+    assert "single value" in filters[valid_attr]
+    assert "list value 1" in filters[valid_attr]
+    assert "list value 2" in filters[valid_attr]
+    assert "repeated list value" in filters[valid_attr]
+
+
+def test_recipe_output_add_to_filters_no_attributes():
+    """Test `RecipeOutput._add_to_filters` with no attributes."""
+
+    filters = {}
+    recipe_output.RecipeOutput._add_to_filters(filters, {})
+    assert len(filters) == 0
+
+
+def test_recipe_output_add_to_filters_no_valid_attributes():
+    """Test `RecipeOutput._add_to_filters` with no valid attributes."""
+
+    filters = {}
+    invalid = "invalid_attribute"
+    recipe_output.RecipeOutput._add_to_filters(filters, {invalid: "value"})
+    assert (invalid not in recipe_output.RecipeOutput.FILTER_ATTRS
+            and len(filters) == 0)
+
+
+def test_recipe_output_sort_filters():
+    """Coverage test for `RecipeOutput._sort_filters`."""
+
+    filters = {}
+    valid_attr = recipe_output.RecipeOutput.FILTER_ATTRS[0]
+    unsorted_attributes = ["1", "2", "4", "value", "3"]
+    recipe_output.RecipeOutput._add_to_filters(
+        filters, {valid_attr: unsorted_attributes})
+    filters = recipe_output.RecipeOutput._sort_filters(filters)
+    assert filters[valid_attr] == sorted(unsorted_attributes)