Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ Global filters are applied to the entire dashboard, affecting all sections and g
- Use **All Runs** to set the value to the total number of runs currently matching the other filters.
- Useful for focusing on recent history without changing the date filters.

#### 8. Suite Path

- Filters the dashboard to only include runs that contain at least one suite whose path matches the selected path (or any sub-path beneath it).
- **All** (default) disables the path filter — all runs are shown.
- The filter displays a **breadcrumb navigator**: the current path is shown as a breadcrumb trail, and the immediate children are shown as clickable buttons below it.
- Click a **child button** to drill down into that sub-folder or suite.
- Click any **breadcrumb segment** to jump back up to that level.
- After the run filter is applied, suites and tests are also narrowed to only those matching the selected path prefix — so all graphs and tables reflect only the chosen path.
- A dot next to the label indicates the filter is active.
- The Suite Path filter is applied after all other run-level filters but before the Amount limit, so "most recent X runs" always refers to runs that contain the selected path.

### Filter Profiles

Filter Profiles let you save, name, and reapply a combination of filter settings in one click.
Expand Down
2 changes: 2 additions & 0 deletions robotframework_dashboard/js/eventlisteners.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
setup_lowest_highest_dates,
clear_all_filters,
setup_project_versions_in_select_filter_buttons,
setup_suite_path_navigator,
setup_custom_filters_in_select_filter_buttons,
update_overview_version_select_list,
setup_metadata_filter,
Expand Down Expand Up @@ -251,6 +252,7 @@ function setup_filter_modal() {
setup_runs_in_select_filter_buttons();
setup_runtags_in_select_filter_buttons();
setup_project_versions_in_select_filter_buttons();
setup_suite_path_navigator("All");
setup_custom_filters_in_select_filter_buttons();
// snapshot the default/initial filter state so profile checkboxes can reflect changes
capture_default_filters();
Expand Down
123 changes: 120 additions & 3 deletions robotframework_dashboard/js/filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,21 @@ function setup_filtered_data_and_filters() {
filteredSuites = remove_timezones(filteredSuites);
filteredTests = remove_timezones(filteredTests);
filteredKeywords = remove_timezones(filteredKeywords);
// filter run data
// determine filteredRuns with all run-level filters (suite path + amount last)
filteredRuns = filter_runs(filteredRuns);
filteredRuns = filter_runtags(filteredRuns);
filteredRuns = filter_dates(filteredRuns);
filteredRuns = filter_amount(filteredRuns);
filteredRuns = filter_metadata(filteredRuns);
filteredRuns = filter_project_versions(filteredRuns);
filteredRuns = filter_custom_filters(filteredRuns);
// filter suites and tests based on filtered runs
filteredRuns = filter_runs_by_suite_path(filteredRuns);
filteredRuns = filter_amount(filteredRuns);
// single pass: filter each dependent array against the final filteredRuns
filteredSuites = filter_data(filteredSuites);
filteredTests = filter_data(filteredTests);
filteredKeywords = filter_data(filteredKeywords);
// narrow suites/tests to the selected path prefix (runs already reduced above)
filter_suite_path_data();
// re-sort all filtered data by wall-clock run_start so mixed-timezone datasets
// appear in the correct chronological order on graphs (timestamps may have been
// converted or had their offsets stripped above, so re-sort here is the source of truth)
Expand Down Expand Up @@ -705,6 +708,96 @@ function setup_project_versions_in_select_filter_buttons() {
setup_filter_checkbox_handler_listeners(projectVersionList, allVersionsCheckBox, filterVersionSelectedIndicatorId);
}

// Returns the direct child paths of parentPath from the raw suites data
function get_suite_path_children(parentPath) {
const isRoot = !parentPath || parentPath === "All";
const depth = isRoot ? 0 : parentPath.split(".").length;
const childPaths = new Set();
for (const suite of suites) {
const parts = suite.full_name.split(".");
if (isRoot) {
childPaths.add(parts[0]);
} else if (suite.full_name === parentPath || suite.full_name.startsWith(parentPath + ".")) {
if (parts.length > depth) {
childPaths.add(parts.slice(0, depth + 1).join("."));
}
}
}
return [...childPaths].sort();
}

// Render the suite path breadcrumb + child buttons for the given path and wire click handlers
function setup_suite_path_navigator(path) {
const normalized = (!path || path === "") ? "All" : path;
document.getElementById("suitePathValue").value = normalized;

// Active indicator — shown whenever a real path is selected
const indicator = document.getElementById("filterSuitePathSelectedIndicator");
if (indicator) indicator.style.display = normalized === "All" ? "none" : "";

// Breadcrumb
const breadcrumbEl = document.getElementById("suitePathBreadcrumb");
if (normalized === "All") {
breadcrumbEl.innerHTML = `<span class="text-muted">All</span>`;
} else {
const parts = normalized.split(".");
const segments = [{ label: "All", path: "All" }];
parts.forEach((part, i) => segments.push({ label: part, path: parts.slice(0, i + 1).join(".") }));
breadcrumbEl.innerHTML = segments.map((seg, i) => {
const isLast = i === segments.length - 1;
const escaped = escape_html_for_merge(seg.label);
const escapedPath = escape_html_for_merge(seg.path);
if (isLast) return `<span class="fw-semibold">${escaped}</span>`;
return `<a class="suite-path-nav-link text-decoration-none" data-path="${escapedPath}" style="cursor: pointer;">${escaped}</a>`
+ `<span class="text-muted mx-1">›</span>`;
}).join("");
breadcrumbEl.querySelectorAll(".suite-path-nav-link").forEach(el => {
el.addEventListener("click", () => setup_suite_path_navigator(el.dataset.path));
});
}

// Child buttons
const childrenEl = document.getElementById("suitePathChildren");
const children = get_suite_path_children(normalized);
if (children.length === 0) {
childrenEl.innerHTML = '<span class="text-muted fst-italic">No sub-suites</span>';
} else {
childrenEl.innerHTML = children.map(child => {
const label = child.split(".").pop();
return `<button class="btn btn-outline-light btn-sm suite-path-child-btn" data-path="${escape_html_for_merge(child)}" style="margin-bottom: 3px;">${escape_html_for_merge(label)}</button>`;
}).join("");
childrenEl.querySelectorAll(".suite-path-child-btn").forEach(btn => {
btn.addEventListener("click", () => setup_suite_path_navigator(btn.dataset.path));
});
}

// Show/hide the filter row
document.getElementById("suitePathFilter").hidden = suites.length === 0;
}

// Run-level part of the suite path filter: removes runs that have no suite matching the path.
// Uses filteredSuites (all suites, already timezone/millisecond-transformed) so run_start
// 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));
}

// Data-level part of the suite path filter: narrows filteredSuites/filteredTests to the
// selected path prefix. Called after filter_data so filteredRuns is already final.
function filter_suite_path_data() {
const selectedPath = document.getElementById("suitePathValue").value;
if (!selectedPath || selectedPath === "All") return;

const matches = (full_name) => full_name === selectedPath || full_name.startsWith(selectedPath + ".");
filteredSuites = filteredSuites.filter(s => matches(s.full_name));
filteredTests = filteredTests.filter(t => matches(t.full_name));
}

// create custom filter dropdowns dynamically for each dimension found in run data
function setup_custom_filters_in_select_filter_buttons() {
const container = document.getElementById("customFiltersList");
Expand Down Expand Up @@ -946,12 +1039,17 @@ function generate_version_filter_list_item_html(version, projectName, checked, a
function clear_all_filters() {
clear_project_filter();
clear_version_filter();
clear_suite_path_filter();
clear_custom_filters();
document.getElementById("amount").value = filteredAmountDefault;
document.getElementById("metadata").value = "All";
setup_lowest_highest_dates();
}

function clear_suite_path_filter() {
setup_suite_path_navigator("All");
}

function clear_custom_filters() {
const dimensions = collect_custom_filter_dimensions();
for (const dimName of Object.keys(dimensions)) {
Expand Down Expand Up @@ -1036,6 +1134,7 @@ function compute_profile_check_states() {
profileCheckToTime: ['toTime'],
profileCheckMetadata: ['metadata'],
profileCheckAmount: ['amount'],
profileCheckSuitePaths: ['suitePath'],
};
const result = {};
for (const [checkId, keys] of Object.entries(checkKeyMap)) {
Expand Down Expand Up @@ -1065,6 +1164,8 @@ function capture_current_filters() {
profile.metadata = document.getElementById("metadata").value;
// Amount
profile.amount = document.getElementById("amount").value;
// Suite path
profile.suitePath = document.getElementById("suitePathValue").value;
// Custom filters (one entry per dimension)
const dimensions = collect_custom_filter_dimensions();
if (Object.keys(dimensions).length > 0) {
Expand Down Expand Up @@ -1096,6 +1197,7 @@ function build_profile_from_checks() {
profileCheckToTime: 'toTime',
profileCheckMetadata: 'metadata',
profileCheckAmount: 'amount',
profileCheckSuitePaths: 'suitePath',
};
for (const [checkId, keys] of Object.entries(checkMap)) {
const el = document.getElementById(checkId);
Expand Down Expand Up @@ -1256,6 +1358,13 @@ function apply_filter_profile(profile, name) {
if (profile.amount !== undefined) {
document.getElementById("amount").value = profile.amount;
}
if (profile.suitePath !== undefined) {
setup_suite_path_navigator(profile.suitePath);
} else if (profile.suitePaths !== undefined) {
// Backward compatibility: old format stored an array of {value, checked}
const checked = (profile.suitePaths || []).filter(p => p.checked && p.value !== "All").map(p => p.value);
setup_suite_path_navigator(checked.length === 1 ? checked[0] : "All");
}
if (profile.customFilters !== undefined) {
for (const [dimName, items] of Object.entries(profile.customFilters)) {
const listEl = document.getElementById(`customFilter_${dimName}_List`);
Expand Down Expand Up @@ -1441,6 +1550,13 @@ function merge_two_profiles(profileA, profileB) {
result.amount = profileB.amount;
}

// suitePath: keep if both profiles agree, otherwise reset to "All"
if (profileA.suitePath !== undefined || profileB.suitePath !== undefined) {
const a = profileA.suitePath ?? "All";
const b = profileB.suitePath ?? "All";
result.suitePath = a === b ? a : "All";
}

// customFilters: per-dimension union of checked states
if (profileA.customFilters !== undefined || profileB.customFilters !== undefined) {
const cfA = profileA.customFilters || {};
Expand Down Expand Up @@ -1489,6 +1605,7 @@ export {
setup_testtags_in_select,
setup_keywords_in_select,
setup_project_versions_in_select_filter_buttons,
setup_suite_path_navigator,
setup_custom_filters_in_select_filter_buttons,
setup_filter_checkbox_handler_listeners,
update_overview_version_select_list,
Expand Down
7 changes: 7 additions & 0 deletions robotframework_dashboard/js/variables/globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ const filterRows = [
present: p => p.amount !== undefined,
valueHtml: p => escape_html_for_merge(String(p.amount)),
},
{
key: 'suitePath',
label: 'Suite Path',
fields: ['suitePath'],
present: p => p.suitePath !== undefined,
valueHtml: p => escape_html_for_merge(String(p.suitePath || 'All')),
},
{
key: 'customFilters',
label: 'Custom Filters',
Expand Down
6 changes: 6 additions & 0 deletions robotframework_dashboard/js/variables/information.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ It helps identify tests with inconsistent execution times, which might be flaky
"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.",
"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.
- Use the breadcrumb links to jump back to a parent level.
- 'All' (the default) disables the path filter.
- Suites and tests are also narrowed to the selected path after the run filter is applied.
- A dot next to the label indicates the filter is active.`,
"compareStatisticsGraphBar": "This graph displays the overall statistics of the selected runs",
"compareSuiteDurationGraphRadar": "This graph displays the duration per suite in a radar format",
"compareTestsGraphTimeline": `Timeline of test statuses across the selected runs.
Expand Down
19 changes: 19 additions & 0 deletions robotframework_dashboard/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,25 @@ <h1 class="modal-title" id="filtersModalLabel">Filters</h1>
id="allRuns">All Runs</button>
</div>
</div>
<div class="list-group-item" id="suitePathFilter">
<div class="d-flex justify-content-between align-items-start">
<input class="form-check-input filter-profile-check me-2 mt-2" type="checkbox"
id="profileCheckSuitePaths" checked style="display: none;" />
<span class="d-flex align-items-center information info-label" id="filterSuitePathsInformation">
Suite Path
<span class="info-icon-small ms-1"></span>
<span id="filterSuitePathSelectedIndicator" class="version-selected-dot ms-2 mt-1"
style="display: none;"></span>
</span>
<div style="width: 50%;">
<input type="hidden" id="suitePathValue" value="All" />
<div id="suitePathBreadcrumb" class="d-flex align-items-center flex-wrap gap-1 mb-1"
style="min-height: 1.5rem;"></div>
<div id="suitePathChildren" class="d-flex flex-wrap gap-1"
style="max-height: 150px; overflow-y: auto;"></div>
</div>
</div>
</div>
<div id="customFiltersList"></div>
</div>
</div>
Expand Down
Loading