From d708ddd8341d3ee643ce7addb5f633ba9a3927d3 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Thu, 24 Sep 2026 00:26:36 +0200 Subject: [PATCH 1/2] feat: run counts and greying out of impossible filter options (#296) The filter dropdowns were built once from the full run data and never recomputed, so they kept offering values belonging to runs another filter had already excluded: selecting filter_1=A still offered filter_2=D even though no run has that combination. Every option of the Runs, Run Tags, Versions, Metadata and custom filters now shows how many runs remain if that option is the selection of its filter, and options that match nothing are greyed out. They stay visible and selectable, so no value ever disappears from the modal. The counts follow the usual faceted-search rule: they are computed with every filter except their own applied, so picking a value never makes the other values of that same filter vanish. The amount filter is left out, as "the most recent X runs" is not a category. In NOT mode an option excludes its runs, so its count is what is left over. To keep one implementation of the filter logic, the filter functions were split: the apply_* functions hold the logic and take their selection as an argument, the filter_* functions read the filter modal and call them, and the availability computation reuses the same apply_* functions. Parsed custom_filters and metadata strings are cached, which also removes a per-dimension re-parse from the existing filter pass. Both the counts and the greying out can be turned off in the settings. Open Filter Dialog now waits for the modal's opening animation to finish: Bootstrap ignores a hide() that arrives while the modal is still animating open, which leaves the modal on screen and makes every later click time out. That race was already flaky on main and this change shifted the timing enough to hit it repeatedly. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- docs/filtering.md | 12 + docs/settings.md | 2 + robotframework_dashboard/css/components.css | 12 + robotframework_dashboard/js/eventlisteners.js | 13 + robotframework_dashboard/js/filter.js | 455 +++++++++++++++--- .../js/variables/information.js | 18 +- .../js/variables/settings.js | 2 + .../templates/dashboard.html | 14 + setup.py | 2 +- tests/javascript/filter_availability.test.js | 209 ++++++++ tests/javascript/mocks/graphs.js | 2 + .../keywords/dashboard-keywords.resource | 55 +++ tests/robot/testsuites/06_filters.robot | 34 ++ 14 files changed, 753 insertions(+), 79 deletions(-) create mode 100644 tests/javascript/filter_availability.test.js diff --git a/README.md b/README.md index 079ad3fe..642e8c5b 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ For all available CLI options see the [Basic CLI docs](https://marketsquare.gith - 📄 **Dashboard Pages** - Overview, Dashboard, and Compare pages for multi-level insights. - 🎛️ **Customizable Layouts** - Drag-and-drop sections with adjustable size and order. - 📊 **Graph Customization** - Toggle legends, axis titles, labels, and control animations. -- 🔎 **Global Filters** - Filter runs by name, tags, date, metadata, or quantity. +- 🔎 **Global Filters** - Filter runs by name, tags, date, metadata, or quantity. Every filter option shows how many runs it still matches, and options that match none are greyed out. - ⚖️ **Comparison Mode** - Compare up to 4 runs side by side with visual statistics. - 🔗 **Automatic Log Linking** - Open Robot Framework logs directly from the dashboard. - 🔁 **Rerun History** - Merged `--rerunfailed` outputs keep the status of every attempt per test. diff --git a/docs/filtering.md b/docs/filtering.md index 9aeeaa33..c6b0540a 100644 --- a/docs/filtering.md +++ b/docs/filtering.md @@ -28,6 +28,18 @@ The **Dashboard** page provides both **global filters** and **section-specific f Global filters are applied to the entire dashboard, affecting all sections and graphs. Open the filter modal using the filter icon in the top navigation bar. +#### Run Counts and Unavailable Options + +Every option of the Runs, Run Tags, Versions, Metadata and Custom Filters dropdowns shows how many runs it still matches: + +- The number behind an option is calculated with **all other filters applied**, so it tells you what you get if you select that option. The Amount filter ("most recent X runs") is not included, as it is not a category. +- The count of a filter **ignores that filter itself**. Selecting one Run Tag therefore never changes the counts of the other Run Tags, and they never disappear from the list. +- A count always answers the same question: *how many runs remain if this option is the selection of this filter*. In **NOT** mode an option excludes its runs, so its count is what is left over. +- Options that match no runs at all are **greyed out**. They stay visible and can still be selected (the dashboard then simply shows no runs), so you never lose sight of the values in your data. +- Both can be turned off in [Settings](settings.md): *Display Run Counts in Filter Options* and *Grey Out Filter Options Without Runs*. + +Example: with two custom filters where `filter_1=A` only ever occurs together with `filter_2=C`, selecting `filter_1=A` shows `filter_2=D (0)` greyed out, because no run has that combination. + #### 1. Runs - Filters the dashboard to only show data for runs of the selected project (run name). diff --git a/docs/settings.md b/docs/settings.md index 658c6ea9..1988a135 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -71,6 +71,8 @@ The **Defaults** tab controls initial dashboard behavior and default graph selec | **Unified Dashboard Sections** | Show all dashboard sections in a single unified view (instead of separate run/suite/test/keyword sections). | | **Suite Statistics – Default suite selection (dropdown)** | Selects which suite(s) are shown by default in the Suite Statistics tab. Options: `All Suites Separate`, `All Suites Combined`, or any individual suite. If the selected suite is removed from the data, the first available suite is used automatically. | | **Test Statistics – Default suite selection (dropdown)** | Selects which suite is shown by default in the Test Statistics tab. Options: `All` or any individual suite. If the selected suite is removed from the data, the first available suite is used automatically. | +| **Display Run Counts in Filter Options** | Show behind every filter option how many runs it matches with the other filters applied. See [Filtering](filtering.md#run-counts-and-unavailable-options). | +| **Grey Out Filter Options Without Runs** | Dim the filter options that match no runs with the other filters applied. They stay visible and selectable. | ### Saving Settings diff --git a/robotframework_dashboard/css/components.css b/robotframework_dashboard/css/components.css index 56ef38bb..991d02f5 100644 --- a/robotframework_dashboard/css/components.css +++ b/robotframework_dashboard/css/components.css @@ -115,6 +115,18 @@ display: block; } +/* filter options that match no runs with the other filters applied: still visible and + selectable, only dimmed so it is clear they cannot add anything to the result */ +.filter-option-unavailable { + color: var(--color-text-muted); + opacity: 0.65; +} + +.filter-option-count { + color: var(--color-text-muted); + font-size: 0.85em; +} + .filter-search-wrapper { position: relative; } diff --git a/robotframework_dashboard/js/eventlisteners.js b/robotframework_dashboard/js/eventlisteners.js index fdd25df2..1df55d69 100644 --- a/robotframework_dashboard/js/eventlisteners.js +++ b/robotframework_dashboard/js/eventlisteners.js @@ -41,6 +41,8 @@ import { clear_active_profile, capture_default_filters, merge_two_profiles, + schedule_filter_option_availability_refresh, + set_filter_modal_open, } from "./filter.js" import { camelcase_to_underscore, underscore_to_camelcase } from "./common.js"; import { @@ -163,6 +165,8 @@ function setup_filter_modal() { // eventlistener to catch the closing of the filter modal // Only recompute filtered data and update graphs in-place (no layout rebuild needed) document.getElementById("filtersModal").addEventListener("hide.bs.modal", function () { + // no more writing into the modal while it fades out + set_filter_modal_open(false); show_loading_overlay(); requestAnimationFrame(() => { requestAnimationFrame(() => { @@ -172,6 +176,11 @@ function setup_filter_modal() { }); }); }); + // count the runs behind every filter option and grey out the ones that match nothing, + // also picking up filters that were set while the modal was closed (overview drill down) + document.getElementById("filtersModal").addEventListener("show.bs.modal", function () { + set_filter_modal_open(true); + }); // eventlistener to reset the filters document.getElementById("resetFilters").addEventListener("click", function () { clear_all_filters(); @@ -407,10 +416,12 @@ function setup_filter_modal() { filterModal.addEventListener("change", function () { update_profile_select_display(); update_filters_button_indicator(); + schedule_filter_option_availability_refresh(); }); filterModal.addEventListener("input", function () { update_profile_select_display(); update_filters_button_indicator(); + schedule_filter_option_availability_refresh(); }); } @@ -502,6 +513,8 @@ function setup_settings_modal() { { key: "show.suitesSelectionInSuiteStats", elementId: "toggleSuitesSelectionInSuiteStats", datatype: "string", event: "change" }, { key: "show.suitesSelectionInTestStats", elementId: "toggleSuitesSelectionInTestStats", datatype: "string", event: "change" }, { key: "show.overviewDurationPercentage", elementId: "overviewDurationPercentage", datatype: "number", event: "change" }, + { key: "show.filterAvailability", elementId: "toggleFilterAvailability" }, + { key: "show.filterCounts", elementId: "toggleFilterCounts" }, ].forEach(def => { const handler = create_toggle_handler(def); handler(true); diff --git a/robotframework_dashboard/js/filter.js b/robotframework_dashboard/js/filter.js index 0571246a..69394dcc 100644 --- a/robotframework_dashboard/js/filter.js +++ b/robotframework_dashboard/js/filter.js @@ -163,19 +163,42 @@ function convert_timezone(data) { }); } +// The apply_* functions below hold the actual filter logic and take their selection as an +// argument instead of reading the DOM. The filter_* functions read the filter modal and call +// them, and compute_filter_option_availability reuses them to determine which filter options +// can still produce runs, so both paths always behave identically. +function apply_run_name_filter(runs, selectedRun) { + if (!selectedRun || selectedRun === "All") { return runs; } + return Object.values(runs).filter(run => run.name === selectedRun); +} + // function to filter run data based on the runs (aka run name) filter function filter_runs(runs) { if (selectedRunSetting != '') { document.getElementById("runs").value = selectedRunSetting selectedRunSetting = '' } - const selectedRun = document.getElementById("runs").value; - if (selectedRun === "All") { - var selectedRuns = runs - } else { - var selectedRuns = Object.values(runs).filter(run => run.name === selectedRun) + return apply_run_name_filter(runs, document.getElementById("runs").value); +} + +function apply_runtag_filter(runs, selectedTags, tagMode) { + if (selectedTags.includes("All")) { + return runs; + } + if (selectedTags.length === 0) { + return []; } - return selectedRuns; + return runs.filter(run => { + const runTags = run.tags.split(","); + if (tagMode === "OR") { // Use OR logic: the run must contain at least one selected tag + return selectedTags.some(selectedTag => runTags.includes(selectedTag)); + } + if (tagMode === "NOT") { // Use NOT logic: the run must not contain any selected tag + return !selectedTags.some(selectedTag => runTags.includes(selectedTag)); + } + // Default AND logic: the run must contain all selected tags + return selectedTags.every(selectedTag => runTags.includes(selectedTag)); + }); } // function to filter run data based on the run tags filter @@ -199,22 +222,19 @@ function filter_runtags(runs) { const selectedTags = Array.from(tagElements) .filter(tagElement => tagElement.checked) .map(tagElement => tagElement.id.replace(/^runTagCheckBox/, "")); - if (selectedTags.includes("All")) { - return runs; - } - if (selectedTags.length === 0) { - return []; - } + return apply_runtag_filter(runs, selectedTags, tagMode); +} + +function apply_custom_filter_dimension(runs, dimName, checkedValues, mode) { + if (!checkedValues.size) { return []; } + if (checkedValues.has("All")) { return runs; } return runs.filter(run => { - const runTags = run.tags.split(","); - if (tagMode === "OR") { // Use OR logic: the run must contain at least one selected tag - return selectedTags.some(selectedTag => runTags.includes(selectedTag)); + const effectiveValue = get_custom_filter_value(run, dimName); + if (mode === "NOT") { // Use NOT logic: the run must not have any of the selected values + return !checkedValues.has(effectiveValue); } - if (tagMode === "NOT") { // Use NOT logic: the run must not contain any selected tag - return !selectedTags.some(selectedTag => runTags.includes(selectedTag)); - } - // Default AND logic: the run must contain all selected tags - return selectedTags.every(selectedTag => runTags.includes(selectedTag)); + // Default OR/AND logic: the run must have one of the selected values + return checkedValues.has(effectiveValue); }); } @@ -227,26 +247,22 @@ function filter_custom_filters(filteredRuns) { const checkedValues = new Set( Array.from(listEl.querySelectorAll("input:checked")).map(el => el.value) ); - if (!checkedValues.size) { filteredRuns = []; continue; } - if (checkedValues.has("All")) continue; const modeEl = document.getElementById(`customFilter_${dimName}_Mode`); const mode = modeEl ? modeEl.value : "OR"; - filteredRuns = filteredRuns.filter(run => { - const parsed = parse_custom_filters(run.custom_filters); - const runValue = parsed[dimName]; - const effectiveValue = runValue === undefined ? "None" : runValue; - if (mode === "NOT") { // Use NOT logic: the run must not have any of the selected values - return !checkedValues.has(effectiveValue); - } - // Default OR/AND logic: the run must have one of the selected values - return checkedValues.has(effectiveValue); - }); + filteredRuns = apply_custom_filter_dimension(filteredRuns, dimName, checkedValues, mode); } return filteredRuns; } +// custom_filters strings never change after the data is decoded, so the parsed result is +// cached: the value of one dimension is read once per run per filter pass, and the option +// availability computation walks every run once per dimension on top of that. +const parsedCustomFiltersCache = new Map(); + function parse_custom_filters(cfStr) { if (!cfStr) return {}; + const cached = parsedCustomFiltersCache.get(cfStr); + if (cached) return cached; const result = {}; cfStr.split(":").forEach(part => { const eq = part.indexOf("="); @@ -254,9 +270,16 @@ function parse_custom_filters(cfStr) { result[part.slice(0, eq).trim()] = part.slice(eq + 1).trim(); } }); + parsedCustomFiltersCache.set(cfStr, result); return result; } +// the value of one custom filter dimension for a run, "None" when the run has no such key +function get_custom_filter_value(run, dimName) { + const value = parse_custom_filters(run.custom_filters)[dimName]; + return value === undefined ? "None" : value; +} + function collect_custom_filter_dimensions() { const dimensions = {}; for (const run of runs) { @@ -270,20 +293,51 @@ function collect_custom_filter_dimensions() { return dimensions; } +function apply_project_version_filter(runs, selectedProjectVersions) { + if (!selectedProjectVersions.size) return []; + if (selectedProjectVersions.has("All")) return runs; + + return runs.filter(run => selectedProjectVersions.has(get_project_version_value(run))); +} + +// the project version of a run, "None" when the run has no version label +function get_project_version_value(run) { + return run.project_version === null || run.project_version === undefined ? "None" : run.project_version; +} + // filter run data based on the project version filter -function filter_project_versions(runs) { const selectedProjectVersions = new Set( +function filter_project_versions(runs) { + const selectedProjectVersions = new Set( Array.from( document.querySelectorAll('#projectVersionList input[type="checkbox"]:checked') ).map(el => el.value) ); - if (!selectedProjectVersions.size) return []; - if (selectedProjectVersions.has("All")) return runs; + return apply_project_version_filter(runs, selectedProjectVersions); +} + +// the selected date range as {from, to} Date objects, or null when the range is incomplete +function build_date_range(fromDate, fromTime, toDate, toTime) { + if (!fromDate || !fromTime || !toDate || !toTime) { + return null; + } + const from = new Date(`${fromDate} ${fromTime}:00`); + const to = new Date(`${toDate} ${toTime}:00`); + if (from > to) { + return null; + } + return { from, to }; +} +function apply_date_filter(runs, fromDateTime, toDateTime) { return runs.filter(run => { - if (run.project_version === null) { // allow filter for runs with no project version - return selectedProjectVersions.has("None"); + // When not converting timezones, strip any timezone offset so the run_start is treated + // as a plain wall-clock time matching the date picker values (which are also wall-clock). + let rs = run.run_start.replace(" ", "T"); + if (!settings.show.convertTimezone) { + rs = strip_tz_suffix(rs); } - return selectedProjectVersions.has(run.project_version); + const runStart = new Date(rs); + return runStart >= fromDateTime && runStart <= toDateTime; }); } @@ -296,22 +350,12 @@ function filter_dates(runs) { if (!fromDate || !fromTime || !toDate || !toTime) { return runs; } - const fromDateTime = new Date(`${fromDate} ${fromTime}:00`); - const toDateTime = new Date(`${toDate} ${toTime}:00`); - if (fromDateTime > toDateTime) { + const dateRange = build_date_range(fromDate, fromTime, toDate, toTime); + if (!dateRange) { // build_date_range only rejects a complete range when from is later than to alert("Filter error: The selected from date + time is later than your selected to date + time. Date filter has not been applied!"); return runs; } - return runs.filter(run => { - // When not converting timezones, strip any timezone offset so the run_start is treated - // as a plain wall-clock time matching the date picker values (which are also wall-clock). - let rs = run.run_start.replace(" ", "T"); - if (!settings.show.convertTimezone) { - rs = strip_tz_suffix(rs); - } - const runStart = new Date(rs); - return runStart >= fromDateTime && runStart <= toDateTime; - }); + return apply_date_filter(runs, dateRange.from, dateRange.to); } // function to filter the amount of runs based on the filter @@ -344,17 +388,14 @@ function filter_amount(filteredRuns) { return filteredRuns } +function apply_metadata_filter(filteredRuns, selectedMetadata) { + if (selectedMetadata == '' || selectedMetadata == 'All' || selectedMetadata == undefined) return filteredRuns; + return filteredRuns.filter(run => (run.metadata || "").includes(selectedMetadata)); +} + // function to filter the runs based on the selected metadata key:value pair function filter_metadata(filteredRuns) { - const selectedMetadata = document.getElementById("metadata").value; - if (selectedMetadata == '' || selectedMetadata == 'All') return filteredRuns; - var filteredData = [] - for (const run of filteredRuns) { - if (run.metadata.includes(selectedMetadata)) { - filteredData.push(run) - } - } - return filteredData + return apply_metadata_filter(filteredRuns, document.getElementById("metadata").value); } // function to filter suites/tests/keywords based on the already filtered runs @@ -598,16 +639,31 @@ function setup_lowest_highest_dates() { } } +// metadata strings never change after the data is decoded, so the parsed items are cached: +// the option list is rebuilt on every refresh of the filter option counts +const parsedMetadataCache = new Map(); + +function parse_metadata_items(metadata) { + if (!metadata) return []; + const cached = parsedMetadataCache.get(metadata); + if (cached) return cached; + const parsed = JSON.parse(metadata.replace(/'/g, '"')); + parsedMetadataCache.set(metadata, parsed); + return parsed; +} + +// the metadata key:value pairs found in the run data, sorted for display +function get_metadata_options(runList = runs) { + const metadataItems = new Set(); + for (const run of runList) { + parse_metadata_items(run.metadata).forEach(item => metadataItems.add(item)); + } + return Array.from(metadataItems).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); +} + // function to setup metadata filter if there is metadata in the data function setup_metadata_filter() { - var metadataItems = new Set(); - for (const run of runs) { - if (!run.metadata) continue; - const jsonStr = run.metadata.replace(/'/g, '"'); - const parsed = JSON.parse(jsonStr); - parsed.forEach(item => metadataItems.add(item)); - } - metadataItems = Array.from(metadataItems).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + const metadataItems = get_metadata_options(); const metadataFilter = document.getElementById("metadataFilter"); if (metadataItems.length > 0) { metadataFilter.hidden = false; @@ -767,6 +823,16 @@ function setup_suite_path_navigator(path) { } document.getElementById("suitePathFilter").hidden = suites.length === 0; + // the path lives in a hidden input, so no change event reaches the filter modal listener + schedule_filter_option_availability_refresh(); +} + +function apply_suite_path_run_filter(runs, selectedPath, suiteList) { + if (!selectedPath || selectedPath === "All") return runs; + + const matches = (full_name) => full_name === selectedPath || full_name.startsWith(selectedPath + "."); + const validRunStarts = new Set(suiteList.filter(s => matches(s.full_name)).map(s => s.run_start)); + return runs.filter(r => validRunStarts.has(r.run_start)); } // Run-level part of the suite path filter: removes runs that have no suite matching the path. @@ -774,11 +840,7 @@ function setup_suite_path_navigator(path) { // values line up with the transformed filteredRuns entries. function filter_runs_by_suite_path(runs) { const selectedPath = document.getElementById("suitePathValue").value; - if (!selectedPath || selectedPath === "All") return runs; - - const matches = (full_name) => full_name === selectedPath || full_name.startsWith(selectedPath + "."); - const validRunStarts = new Set(filteredSuites.filter(s => matches(s.full_name)).map(s => s.run_start)); - return runs.filter(r => validRunStarts.has(r.run_start)); + return apply_suite_path_run_filter(runs, selectedPath, filteredSuites); } // Data-level part of the suite path filter: narrows filteredSuites/filteredTests to the @@ -877,6 +939,246 @@ function setup_custom_filters_in_select_filter_buttons() { } } +// Filter option availability: every filter option shows how many runs it would still match +// given the other filters, and options that cannot match anything are greyed out. Without this +// a dropdown keeps offering values that belong to runs another filter already excluded. +// The counts follow the usual faceted-search rule: the count of an option in filter X is +// computed with every filter except X applied, so selecting a value in X never makes the other +// values of X disappear. The amount filter ("last X runs") is not a category and is left out. +let filterBaseRunsCache = { key: null, runs: null, suites: null }; + +// key of the settings that change run_start representation, and with it which runs a date +// range matches and which suites belong to which run +function get_filter_base_cache_key() { + return `${settings.show.milliseconds}|${settings.show.convertTimezone}|${settings.show.timezones}`; +} + +function apply_run_start_transformations(data) { + return remove_timezones(convert_timezone(remove_milliseconds(data))); +} + +// all runs with the same run_start transformations the filter pipeline applies, so the +// availability computation compares the same timestamps as the real filters do +function get_filter_base_runs() { + const key = get_filter_base_cache_key(); + if (filterBaseRunsCache.key !== key) { + filterBaseRunsCache = { key: key, runs: apply_run_start_transformations(runs), suites: null }; + } + return filterBaseRunsCache.runs; +} + +// suites are only needed while a suite path is selected, so they are transformed on demand +function get_filter_base_suites() { + get_filter_base_runs(); + if (filterBaseRunsCache.suites === null) { + filterBaseRunsCache.suites = apply_run_start_transformations(suites); + } + return filterBaseRunsCache.suites; +} + +// turn the profile object of capture_current_filters() into the selection shape the apply_* +// functions take +function normalize_filter_selections(profile) { + const checked_values = (items, key) => new Set((items || []).filter(item => item.checked).map(item => item[key])); + const customFilters = {}; + for (const [dimName, items] of Object.entries(profile.customFilters || {})) { + customFilters[dimName] = { + values: checked_values(items, "value"), + mode: (profile.customFilterModes || {})[dimName] ?? "OR", + }; + } + return { + runs: profile.runs ?? "All", + runTags: Array.from(checked_values(profile.runTags, "id")), + tagMode: profile.tagMode ?? "AND", + projectVersions: checked_values(profile.projectVersions, "value"), + metadata: profile.metadata ?? "All", + suitePath: profile.suitePath ?? "All", + dateRange: build_date_range(profile.fromDate, profile.fromTime, profile.toDate, profile.toTime), + customFilters: customFilters, + }; +} + +// apply every run level filter except the one whose options are being counted +function apply_filters_except(runList, selections, facet, dimName = null, suiteList = null) { + let result = runList; + if (facet !== "runs") { result = apply_run_name_filter(result, selections.runs); } + if (facet !== "runTags") { result = apply_runtag_filter(result, selections.runTags, selections.tagMode); } + if (facet !== "metadata") { result = apply_metadata_filter(result, selections.metadata); } + if (facet !== "projectVersions") { result = apply_project_version_filter(result, selections.projectVersions); } + for (const [dim, dimSelection] of Object.entries(selections.customFilters)) { + if (facet === "customFilters" && dim === dimName) { continue; } + result = apply_custom_filter_dimension(result, dim, dimSelection.values, dimSelection.mode); + } + // the date range and the suite path have no option lists to count, so they always apply + if (selections.dateRange) { + result = apply_date_filter(result, selections.dateRange.from, selections.dateRange.to); + } + if (selections.suitePath && selections.suitePath !== "All") { + result = apply_suite_path_run_filter(result, selections.suitePath, suiteList ?? get_filter_base_suites()); + } + return result; +} + +function count_option(counts, value) { + counts.set(value, (counts.get(value) ?? 0) + 1); +} + +// every option of a filter starts at 0, so options that no longer match any run are part of +// the result instead of missing from it +function seed_option_counts(values) { + const counts = new Map(); + for (const value of values) { counts.set(value, 0); } + return counts; +} + +// In NOT mode an option excludes its runs instead of selecting them, so its count is the +// number of runs that would be left over. "All" means "no filtering" in every mode and keeps +// the total. This way a count always answers the same question: how many runs remain if this +// option is the selection of this filter. +function invert_counts_for_not_mode(counts, mode, total) { + if (mode !== "NOT") { return counts; } + for (const [value, count] of counts) { + if (value !== "All") { counts.set(value, total - count); } + } + return counts; +} + +// how many runs every option of every filter would match, as {facet: Map(option -> count)}. +// Multi valued filters (run tags, metadata) count a run for each of its values, so their +// counts do not add up to the total, which is what the "All" option holds. +function compute_filter_option_availability(runList, selections, suiteList = null) { + const availability = { customFilters: {} }; + + availability.runs = seed_option_counts(runList.map(run => run.name)); + const runNameBase = apply_filters_except(runList, selections, "runs", null, suiteList); + availability.runs.set("All", runNameBase.length); + for (const run of runNameBase) { count_option(availability.runs, run.name); } + + const all_tags = (run) => run.tags.split(",").filter(tag => tag); + availability.runTags = seed_option_counts(runList.flatMap(all_tags)); + const runTagBase = apply_filters_except(runList, selections, "runTags", null, suiteList); + availability.runTags.set("All", runTagBase.length); + for (const run of runTagBase) { + for (const tag of all_tags(run)) { count_option(availability.runTags, tag); } + } + invert_counts_for_not_mode(availability.runTags, selections.tagMode, runTagBase.length); + + availability.projectVersions = seed_option_counts(runList.map(get_project_version_value)); + const versionBase = apply_filters_except(runList, selections, "projectVersions", null, suiteList); + availability.projectVersions.set("All", versionBase.length); + for (const run of versionBase) { count_option(availability.projectVersions, get_project_version_value(run)); } + + // the metadata filter matches its value against the whole metadata string of a run, so the + // count of an option has to use the same substring check instead of the parsed items + availability.metadata = new Map(); + const metadataBase = apply_filters_except(runList, selections, "metadata", null, suiteList); + availability.metadata.set("All", metadataBase.length); + for (const option of get_metadata_options(runList)) { + availability.metadata.set(option, apply_metadata_filter(metadataBase, option).length); + } + + for (const [dimName, dimSelection] of Object.entries(selections.customFilters)) { + const counts = seed_option_counts(runList.map(run => get_custom_filter_value(run, dimName))); + const dimBase = apply_filters_except(runList, selections, "customFilters", dimName, suiteList); + counts.set("All", dimBase.length); + for (const run of dimBase) { count_option(counts, get_custom_filter_value(run, dimName)); } + availability.customFilters[dimName] = invert_counts_for_not_mode(counts, dimSelection.mode, dimBase.length); + } + + return availability; +} + +// add or update the "(X)" count behind one filter option label +function set_filter_option_count(labelElement, count) { + if (!labelElement) { return; } + let countElement = labelElement.querySelector(".filter-option-count"); + if (!settings.show.filterCounts) { + countElement?.remove(); + return; + } + if (!countElement) { + countElement = document.createElement("span"); + countElement.className = "filter-option-count ms-1"; + labelElement.appendChild(countElement); + } + countElement.textContent = `(${count})`; +} + +// Unavailable options are only greyed out, never disabled or hidden: the user keeps seeing +// that the option exists and can still select it (which then simply results in no runs). +function apply_availability_to_checkbox_list(listElement, counts) { + if (!listElement) { return; } + const inputs = listElement.querySelectorAll("input.form-check-input:not([role='switch'])"); + for (const input of inputs) { + const count = counts?.get(input.value) ?? 0; + const row = input.closest("li"); + set_filter_option_count(row?.querySelector("label.form-check-label"), count); + row?.classList.toggle("filter-option-unavailable", Boolean(counts) && settings.show.filterAvailability && count === 0); + } +} + +function apply_availability_to_select(selectElement, counts) { + if (!selectElement) { return; } + for (const option of selectElement.options) { + const count = counts?.get(option.value) ?? 0; + option.textContent = (counts && settings.show.filterCounts) ? `${option.value} (${count})` : option.value; + option.classList.toggle("filter-option-unavailable", Boolean(counts) && settings.show.filterAvailability && count === 0); + } +} + +// walk every filter option list and drop the counts and the greying out +function clear_filter_option_availability() { + apply_availability_to_select(document.getElementById("runs"), null); + apply_availability_to_select(document.getElementById("metadata"), null); + apply_availability_to_checkbox_list(document.getElementById("runTag"), null); + apply_availability_to_checkbox_list(document.getElementById("projectVersionList"), null); + for (const dimName of Object.keys(collect_custom_filter_dimensions())) { + apply_availability_to_checkbox_list(document.getElementById(`customFilter_${dimName}_List`), null); + } +} + +// The refresh writes into the filter modal, which is only safe while the modal is open: +// changing its contents during the closing animation competes with the fade out and can leave +// the modal on screen. Filters can also be set programmatically while the modal is closed +// (overview drill down, filter profiles), so the state is refreshed when it opens. +let filterModalIsOpen = false; + +function set_filter_modal_open(isOpen) { + filterModalIsOpen = isOpen; + if (isOpen) { refresh_filter_option_availability(); } +} + +// recompute the counts of all filter options and grey out the ones that match no runs +function refresh_filter_option_availability() { + if (!filterModalIsOpen || !document.getElementById("filtersModal")) { return; } + if (!settings.show.filterAvailability && !settings.show.filterCounts) { + clear_filter_option_availability(); + return; + } + const selections = normalize_filter_selections(capture_current_filters()); + const availability = compute_filter_option_availability(get_filter_base_runs(), selections); + apply_availability_to_select(document.getElementById("runs"), availability.runs); + apply_availability_to_select(document.getElementById("metadata"), availability.metadata); + apply_availability_to_checkbox_list(document.getElementById("runTag"), availability.runTags); + apply_availability_to_checkbox_list(document.getElementById("projectVersionList"), availability.projectVersions); + for (const [dimName, counts] of Object.entries(availability.customFilters)) { + apply_availability_to_checkbox_list(document.getElementById(`customFilter_${dimName}_List`), counts); + } +} + +// The filter modal fires a change event per checkbox, and the version search box checks a whole +// set of them at once, so the refresh is collapsed into one call per frame. +let filterAvailabilityFrame = null; + +function schedule_filter_option_availability_refresh() { + if (filterAvailabilityFrame !== null) { return; } + filterAvailabilityFrame = requestAnimationFrame(() => { + filterAvailabilityFrame = null; + refresh_filter_option_availability(); + }); +} + // show filter active indicator if checkBoxElement unchecked function setup_filter_active_indicator(checkBoxElement, filterActiveIndicatorId) { checkBoxElement.addEventListener("change", () => { @@ -976,6 +1278,7 @@ function clear_all_filters() { if (runsIndicator) runsIndicator.style.display = "none"; const filtersActiveIndicator = document.getElementById("filtersActiveIndicator"); if (filtersActiveIndicator) filtersActiveIndicator.style.display = "none"; + schedule_filter_option_availability_refresh(); } function clear_suite_path_filter() { @@ -1298,6 +1601,7 @@ function apply_filter_profile(profile, name) { if (runsIndicator) runsIndicator.style.display = runsVal && runsVal !== "All" ? "inline-block" : "none"; + schedule_filter_option_availability_refresh(); } function load_filter_profiles() { @@ -1521,4 +1825,9 @@ export { merge_two_profiles, collect_custom_filter_dimensions, parse_custom_filters, + compute_filter_option_availability, + normalize_filter_selections, + refresh_filter_option_availability, + schedule_filter_option_availability_refresh, + set_filter_modal_open, }; \ No newline at end of file diff --git a/robotframework_dashboard/js/variables/information.js b/robotframework_dashboard/js/variables/information.js index 917b7d46..2adaa932 100644 --- a/robotframework_dashboard/js/variables/information.js +++ b/robotframework_dashboard/js/variables/information.js @@ -143,22 +143,30 @@ It helps identify tests with inconsistent execution times, which might be flaky - A dot means the active profile's filters have been modified since it was applied. - Update Profile: overwrites the saved profile with the current filter values. - Merge Profiles: combine two profiles using the widest coverage for each filter.`, - "filterRunsInformation": "Filter by project name. 'All' includes every project.", + "filterRunsInformation": `Filter by project name. 'All' includes every project. +- The number behind an option is how many runs remain if you select it (the other filters stay as they are). +- Greyed out options match no runs, but can still be selected.`, "filterRunTagsInformation": `Filter by run tags. 'All' disables the filter. - AND mode (default): run must have all selected tags. - OR mode: run needs at least one selected tag. - NOT mode: run must not have any selected tag. -- A dot indicates the filter is active.`, +- A dot indicates the filter is active. +- The number behind an option is how many runs remain if you select it (the other filters stay as they are). +- Greyed out options match no runs, but can still be selected.`, "filterVersionsInformation": `Filter by project version. 'All' disables the filter. - 'None' covers runs without a version label. - Typing in the search box selects every matching version (e.g. "1." selects all 1.x). - The X in the search box clears it while keeping the current selection. -- A dot indicates the filter is active.`, +- A dot indicates the filter is active. +- The number behind an option is how many runs remain if you select it (the other filters stay as they are). +- Greyed out options match no runs, but can still be selected.`, "filterFromDateInformation": "Show only runs that started on or after this date.", "filterFromTimeInformation": "Show only runs that started at or after this time (combined with From Date).", "filterToDateInformation": "Show only runs that started on or before this date.", "filterToTimeInformation": "Show only runs that started at or before this time (combined with To Date).", - "filterMetadataInformation": "Filter by a metadata value attached to the run. Only shown when runs have metadata.", + "filterMetadataInformation": `Filter by a metadata value attached to the run. Only shown when runs have metadata. +- The number behind an option is how many runs remain if you select it (the other filters stay as they are). +- Greyed out options match no runs, but can still be selected.`, "filterAmountInformation": "Limit to the most recent X runs after all other filters are applied. 'All Runs' sets this to the total matching count.", "filterSuitePathsInformation": `Filter runs by suite path. Only runs that contain at least one suite matching the selected path (or any of its sub-paths) are shown. - Navigate into sub-folders by clicking a child button. @@ -185,6 +193,8 @@ Tip: avoid using Status and Only Changes together — the result will be empty.` "settingConvertTimezone": "Convert stored run_start timestamps to your browser's local timezone. Only applies to runs that have a stored timezone offset.", "settingSuiteStatsDefault": "Default suite shown in the Suite Statistics tab when the dashboard opens.", "settingTestStatsDefault": "Default suite shown in the Test Statistics tab when the dashboard opens.", + "settingFilterCounts": "Show behind every filter option how many runs remain if that option is the selection of its filter. The count is calculated with all other filters applied, and ignores the amount filter.", + "settingFilterAvailability": "Grey out filter options that match no runs with the other filters as they are. The options stay visible and selectable.", "settingLatestRuns": "Show the Latest Runs bar with the most recent run per project, color-coded by duration.", "settingTotalStats": "Show the Total Stats bar with aggregate pass/fail/skip counts and average pass rates per project.", "settingProjectsByName": "Group and display projects on the Overview by their Robot Framework run name.", diff --git a/robotframework_dashboard/js/variables/settings.js b/robotframework_dashboard/js/variables/settings.js index 19d88e82..6b3d1c75 100644 --- a/robotframework_dashboard/js/variables/settings.js +++ b/robotframework_dashboard/js/variables/settings.js @@ -58,6 +58,8 @@ var settings = { convertTimezone: false, suitesSelectionInSuiteStats: "First Suite", suitesSelectionInTestStats: "First Suite", + filterAvailability: true, + filterCounts: true, }, theme_colors: { light: { diff --git a/robotframework_dashboard/templates/dashboard.html b/robotframework_dashboard/templates/dashboard.html index 5debba8d..3c309bad 100644 --- a/robotframework_dashboard/templates/dashboard.html +++ b/robotframework_dashboard/templates/dashboard.html @@ -847,6 +847,20 @@

Settings

+
+ Display Run Counts in Filter Options +
+ +
+
+
+ Grey Out Filter Options Without Runs +
+ +
+
import('./mocks/data.js')); +vi.mock('@js/variables/globals.js', () => import('./mocks/globals.js')); +vi.mock('@js/variables/graphs.js', () => import('./mocks/graphs.js')); + +import { compute_filter_option_availability, normalize_filter_selections } from '@js/filter.js'; + +// The example from issue #296: two custom filter dimensions where filter_1=A only ever +// occurs together with filter_2=C. +function issue_296_runs() { + return [ + make_run({ name: 'test_1', run_start: '2026-01-01 10:00:00', custom_filters: 'filter_1=A:filter_2=C' }), + make_run({ name: 'test_2', run_start: '2026-01-02 10:00:00', custom_filters: 'filter_1=A:filter_2=C' }), + make_run({ name: 'test_3', run_start: '2026-01-03 10:00:00', custom_filters: 'filter_1=B:filter_2=D' }), + make_run({ name: 'test_4', run_start: '2026-01-04 10:00:00', custom_filters: 'filter_1=B:filter_2=D' }), + ]; +} + +function make_run(run) { + return { + name: 'run', + run_start: '2026-01-01 10:00:00', + tags: '', + project_version: null, + metadata: '[]', + custom_filters: '', + ...run, + }; +} + +// builds the profile shape of capture_current_filters(); every filter left out of the +// arguments defaults to "no filtering" +function selections({ runs = 'All', runTags = [], tagMode = 'AND', projectVersions = [], metadata = 'All', + suitePath = 'All', customFilters = {}, customFilterModes = {}, dates = {} } = {}) { + const checkboxes = (values, allChecked) => [ + { value: 'All', checked: allChecked }, + ...values.map(value => ({ value: value, checked: true })), + ]; + const profile = { + runs: runs, + runTags: [{ id: 'All', checked: runTags.length === 0 }, ...runTags.map(tag => ({ id: tag, checked: true }))], + tagMode: tagMode, + projectVersions: checkboxes(projectVersions, projectVersions.length === 0), + metadata: metadata, + suitePath: suitePath, + customFilters: {}, + customFilterModes: customFilterModes, + ...dates, + }; + for (const [dimName, values] of Object.entries(customFilters)) { + profile.customFilters[dimName] = checkboxes(values, values.length === 0); + } + return normalize_filter_selections(profile); +} + +// the counts of one custom filter dimension as a plain object, for readable assertions +function custom_counts(availability, dimName) { + return Object.fromEntries(availability.customFilters[dimName]); +} + +describe('filter option availability', () => { + describe('custom filters influencing each other (issue #296)', () => { + it('counts every value when no filter is selected', () => { + const availability = compute_filter_option_availability( + issue_296_runs(), + selections({ customFilters: { filter_1: [], filter_2: [] } }) + ); + expect(custom_counts(availability, 'filter_1')).toEqual({ All: 4, A: 2, B: 2 }); + expect(custom_counts(availability, 'filter_2')).toEqual({ All: 4, C: 2, D: 2 }); + }); + + it('reports the values of another dimension that the selection excludes as 0', () => { + const availability = compute_filter_option_availability( + issue_296_runs(), + selections({ customFilters: { filter_1: ['A'], filter_2: [] } }) + ); + // filter_2=D only exists on runs with filter_1=B, so it can no longer match + expect(custom_counts(availability, 'filter_2')).toEqual({ All: 2, C: 2, D: 0 }); + }); + + it('leaves the counts of the selected dimension itself untouched', () => { + const availability = compute_filter_option_availability( + issue_296_runs(), + selections({ customFilters: { filter_1: ['A'], filter_2: [] } }) + ); + // selecting A does not hide B: the counts of a filter ignore that filter itself + expect(custom_counts(availability, 'filter_1')).toEqual({ All: 4, A: 2, B: 2 }); + }); + + it('narrows both ways', () => { + const availability = compute_filter_option_availability( + issue_296_runs(), + selections({ customFilters: { filter_1: [], filter_2: ['D'] } }) + ); + expect(custom_counts(availability, 'filter_1')).toEqual({ All: 2, A: 0, B: 2 }); + }); + + it('takes the mode of the other dimension into account', () => { + const availability = compute_filter_option_availability( + issue_296_runs(), + selections({ + customFilters: { filter_1: ['A'], filter_2: [] }, + customFilterModes: { filter_1: 'NOT', filter_2: 'OR' }, + }) + ); + // everything except filter_1=A remains, which is exactly the filter_2=D runs + expect(custom_counts(availability, 'filter_2')).toEqual({ All: 2, C: 0, D: 2 }); + }); + + it('counts what is left over when the dimension itself is in NOT mode', () => { + const availability = compute_filter_option_availability( + issue_296_runs(), + selections({ + customFilters: { filter_1: [], filter_2: [] }, + customFilterModes: { filter_1: 'NOT', filter_2: 'OR' }, + }) + ); + // in NOT mode a value excludes its runs, so selecting A leaves the two B runs + expect(custom_counts(availability, 'filter_1')).toEqual({ All: 4, A: 2, B: 2 }); + const runs = [ + make_run({ custom_filters: 'filter_1=A' }), + make_run({ custom_filters: 'filter_1=A' }), + make_run({ custom_filters: 'filter_1=B' }), + ]; + const notMode = compute_filter_option_availability(runs, selections({ + customFilters: { filter_1: [] }, + customFilterModes: { filter_1: 'NOT' }, + })); + expect(custom_counts(notMode, 'filter_1')).toEqual({ All: 3, A: 1, B: 2 }); + }); + + it('counts runs without the dimension as None', () => { + const runs = [ + make_run({ custom_filters: 'filter_1=A' }), + make_run({ custom_filters: 'filter_2=C' }), + ]; + const availability = compute_filter_option_availability( + runs, + selections({ customFilters: { filter_1: [], filter_2: [] } }) + ); + expect(custom_counts(availability, 'filter_1')).toEqual({ All: 2, A: 1, None: 1 }); + }); + }); + + describe('the other filters', () => { + const runs = [ + make_run({ name: 'alpha', tags: 'nightly,linux', project_version: '1.0', metadata: '["env:prod"]' }), + make_run({ name: 'alpha', tags: 'nightly,windows', project_version: '1.1', metadata: '["env:test"]' }), + make_run({ name: 'beta', tags: 'release,linux', project_version: null, metadata: '["env:prod"]' }), + ]; + + it('counts run names with the run tag filter applied', () => { + const availability = compute_filter_option_availability(runs, selections({ runTags: ['nightly'] })); + expect(Object.fromEntries(availability.runs)).toEqual({ All: 2, alpha: 2, beta: 0 }); + }); + + it('counts run tags per run, so they do not add up to All', () => { + const availability = compute_filter_option_availability(runs, selections({ runs: 'alpha' })); + expect(Object.fromEntries(availability.runTags)) + .toEqual({ All: 2, nightly: 2, linux: 1, windows: 1, release: 0 }); + }); + + it('counts what is left over when the run tags are in NOT mode', () => { + const availability = compute_filter_option_availability(runs, selections({ tagMode: 'NOT' })); + // excluding nightly leaves the one release run, excluding linux leaves the windows one + expect(Object.fromEntries(availability.runTags)) + .toEqual({ All: 3, nightly: 1, linux: 1, windows: 2, release: 2 }); + }); + + it('counts project versions and reports runs without one as None', () => { + const availability = compute_filter_option_availability(runs, selections({ runTags: ['linux'] })); + expect(Object.fromEntries(availability.projectVersions)) + .toEqual({ All: 2, '1.0': 1, '1.1': 0, None: 1 }); + }); + + it('counts metadata values with the other filters applied', () => { + const availability = compute_filter_option_availability(runs, selections({ projectVersions: ['1.1'] })); + expect(Object.fromEntries(availability.metadata)) + .toEqual({ All: 1, 'env:prod': 0, 'env:test': 1 }); + }); + + it('applies the date range to every filter', () => { + const dated = [ + make_run({ name: 'alpha', run_start: '2026-01-01 10:00:00' }), + make_run({ name: 'beta', run_start: '2026-02-01 10:00:00' }), + ]; + const availability = compute_filter_option_availability(dated, selections({ + dates: { fromDate: '2026-01-01', fromTime: '00:00', toDate: '2026-01-31', toTime: '23:59' }, + })); + expect(Object.fromEntries(availability.runs)).toEqual({ All: 1, alpha: 1, beta: 0 }); + }); + + it('applies the selected suite path to every filter', () => { + const suites = [ + { full_name: 'Webshop.Login', run_start: runs[0].run_start }, + ]; + const availability = compute_filter_option_availability( + [make_run({ name: 'alpha', run_start: '2026-01-01 10:00:00' }), make_run({ name: 'beta', run_start: '2026-03-03 10:00:00' })], + selections({ suitePath: 'Webshop' }), + suites + ); + expect(Object.fromEntries(availability.runs)).toEqual({ All: 1, alpha: 1, beta: 0 }); + }); + }); +}); diff --git a/tests/javascript/mocks/graphs.js b/tests/javascript/mocks/graphs.js index 31826e8d..4b21af34 100644 --- a/tests/javascript/mocks/graphs.js +++ b/tests/javascript/mocks/graphs.js @@ -14,6 +14,7 @@ const unifiedSections = ["Dashboard Statistics"]; const compareSections = ["Compare Statistics"]; const tableSections = ["Table Statistics"]; const dashboardGraphs = []; +const defaultHiddenDashboardGraphs = []; const compareGraphs = []; const tableGraphs = []; @@ -31,6 +32,7 @@ export { compareSections, tableSections, dashboardGraphs, + defaultHiddenDashboardGraphs, compareGraphs, tableGraphs, }; diff --git a/tests/robot/resources/keywords/dashboard-keywords.resource b/tests/robot/resources/keywords/dashboard-keywords.resource index c1702fa1..b0d7ce87 100644 --- a/tests/robot/resources/keywords/dashboard-keywords.resource +++ b/tests/robot/resources/keywords/dashboard-keywords.resource @@ -125,10 +125,17 @@ Get Test Count With Attempts RETURN ${count} Open Filter Dialog + [Documentation] Bootstrap ignores a hide() that arrives while the modal is still animating + ... open, which leaves the modal on screen for good and makes every later click fail, so + ... wait until the opening animation is really finished. Click selector=id=filters + Wait For Function + ... () => { const modal = document.getElementById("filtersModal"); return modal.classList.contains("show") && getComputedStyle(modal).opacity === "1" && !bootstrap.Modal.getInstance(modal)?._isTransitioning; } + ... timeout=10s Close Filter Dialog Click selector=id=closeFilters + Wait For Elements State selector=id=filtersModal state=hidden timeout=10s Modal Buttons Should Be Right Aligned [Documentation] The dashboard modals use their own button group instead of Bootstrap's .btn-close; @@ -680,6 +687,54 @@ Set Custom Filter Mode Select Options By id=customFilter_${dimension}_Mode value ${mode} Close Filter Dialog +Check Custom Filter Value + [Documentation] Checks one value of a custom filter and closes its dropdown again, without + ... closing the filter dialog, so the effect on the other filters can be inspected. + [Arguments] ${dimension} ${value} + Click selector=id=selectCustomFilter_${dimension} + Check Checkbox selector=id=customFilter_${dimension}_List_${value} + Click selector=id=selectCustomFilter_${dimension} + +# filter option availability (run counts + greying out of options that match no runs) +Get Custom Filter Option Counts + [Documentation] The run count shown behind every value of one custom filter dropdown, as + ... {value: count}. Values without a count are left out. + [Arguments] ${dimension} + ${counts} Evaluate JavaScript ${None} + ... () => Object.fromEntries([...document.querySelectorAll('#customFilter_${dimension}_List input.form-check-input')].map(input => [input.value, input.closest("li").querySelector(".filter-option-count")]).filter(([value, count]) => count).map(([value, count]) => [value, Number(count.textContent.replace(/[()]/g, ""))])) + RETURN ${counts} + +Get Unavailable Custom Filter Values + [Documentation] The values of one custom filter dropdown that are greyed out because they + ... match no runs with the other filters applied. + [Arguments] ${dimension} + ${values} Evaluate JavaScript ${None} + ... () => [...document.querySelectorAll('#customFilter_${dimension}_List li.filter-option-unavailable input.form-check-input')].map(input => input.value) + RETURN ${values} + +Custom Filter Option Counts Should Be + [Documentation] The counts are refreshed one animation frame after a filter changes, so the + ... comparison is retried for a moment. + [Arguments] ${dimension} ${expected} + Wait Until Keyword Succeeds 5x 200ms Custom Filter Option Counts Are ${dimension} ${expected} + +Custom Filter Option Counts Are + [Documentation] One comparison attempt of Custom Filter Option Counts Should Be. + [Arguments] ${dimension} ${expected} + ${counts} Get Custom Filter Option Counts ${dimension} + Should Be Equal ${counts} ${expected} + +Unavailable Custom Filter Values Should Be + [Documentation] Like Custom Filter Option Counts Should Be, for the greyed out values. + [Arguments] ${dimension} ${expected} + Wait Until Keyword Succeeds 5x 200ms Unavailable Custom Filter Values Are ${dimension} ${expected} + +Unavailable Custom Filter Values Are + [Documentation] One comparison attempt of Unavailable Custom Filter Values Should Be. + [Arguments] ${dimension} ${expected} + ${values} Get Unavailable Custom Filter Values ${dimension} + Should Be Equal ${values} ${expected} + # "All" tab of the Add Stat Widget modal Open Add All Stat Widgets Tab [Documentation] Opens the Add Stat Widget modal of a section (edit mode only) on its "All" tab and diff --git a/tests/robot/testsuites/06_filters.robot b/tests/robot/testsuites/06_filters.robot index eebfa99e..f23ce9ca 100644 --- a/tests/robot/testsuites/06_filters.robot +++ b/tests/robot/testsuites/06_filters.robot @@ -245,3 +245,37 @@ Validate Dashboard Custom Filters Should Show 1 Of 1 Runs Set Custom Filter Browser chrome strict=True Should Show 0 Of 0 Runs + +Filter Options Show Run Counts And Grey Out Values Without Runs + [Documentation] Every filter option shows how many runs it still matches with the other filters + ... applied, and options that can no longer match anything are greyed out (they stay visible and + ... selectable). The count of a filter ignores that filter itself, so selecting one of its values + ... never makes the other values disappear. + [Setup] Run Keywords Generate Dashboard With Custom Filters Open Dashboard + [Teardown] Run Keywords Close Dashboard Remove Database And Dashboard With Index + Open Filter Dialog + Custom Filter Option Counts Should Be Browser ${{ {"All": 5, "None": 1, "chrome": 3, "firefox": 1} }} + Unavailable Custom Filter Values Should Be Browser ${{ [] }} + Check Custom Filter Value Env prod + # the only prod run is the firefox one, so chrome and None cannot match anything anymore + Custom Filter Option Counts Should Be Browser ${{ {"All": 1, "None": 0, "chrome": 0, "firefox": 1} }} + Unavailable Custom Filter Values Should Be Browser ${{ ["None", "chrome"] }} + Custom Filter Option Counts Should Be Env ${{ {"All": 5, "None": 1, "prod": 1, "staging": 3} }} + Unavailable Custom Filter Values Should Be Env ${{ [] }} + Close Filter Dialog + Should Show 1 Of 1 Runs + +Filter Option Counts And Greying Out Can Be Turned Off + [Documentation] Both are settings, for users who do not want the extra numbers or the dimmed + ... options in the filter modal. + [Setup] Run Keywords Generate Dashboard With Custom Filters Open Dashboard + [Teardown] Run Keywords Close Dashboard Remove Database And Dashboard With Index + Toggle Setting toggleFilterCounts tab=defaults + Toggle Setting toggleFilterAvailability tab=defaults + Setting Should Be show.filterCounts ${False} + Setting Should Be show.filterAvailability ${False} + Open Filter Dialog + Check Custom Filter Value Env prod + Custom Filter Option Counts Should Be Browser ${{ {} }} + Unavailable Custom Filter Values Should Be Browser ${{ [] }} + Close Filter Dialog From bbca64c65fa6c7e00c1fa9334387fa5c956e92d8 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Fri, 25 Sep 2026 01:12:18 +0200 Subject: [PATCH 2/2] fix: make the filter dropdowns readable (#296) The dropdown panels were unreadable, most of all with the new greying out of unavailable options. The panels had no surface of their own: only position, so the rows inside them painted the background. Those rows use --color-card, which is rgba(30, 41, 59, 0.9) in dark mode, so the modal rows underneath showed through the open dropdown and the text of both was mixed together. The panels now paint an opaque --color-popover-bg with a border, a radius and a shadow, and the rows inside are transparent. The search box at the top of a panel scrolls with its option list, so it is sticky now. Unavailable options were dimmed with colour *and* opacity: 0.65, which also faded the checkbox and the count - the count being the one thing that explains why the row is greyed out. The opacity is gone (it is ignored on