diff --git a/robotframework_dashboard/css/styling.css b/robotframework_dashboard/css/styling.css index 6c0f9ce..7bedffd 100644 --- a/robotframework_dashboard/css/styling.css +++ b/robotframework_dashboard/css/styling.css @@ -1,3 +1,10 @@ +#overview, +#dashboard, +#unified, +#compare, +#tables { + margin-bottom: 35vh; +} /* LIGHT MODE STYLING */ body { background-color: #eee; diff --git a/robotframework_dashboard/js/eventlisteners.js b/robotframework_dashboard/js/eventlisteners.js index cd0b3a9..c618e0d 100644 --- a/robotframework_dashboard/js/eventlisteners.js +++ b/robotframework_dashboard/js/eventlisteners.js @@ -33,6 +33,7 @@ import { import { create_overview_statistics_graphs, update_overview_statistics_heading, + update_projectbar_visibility, set_filter_show_current_project, set_filter_show_current_version, } from "./graph_creation/overview.js"; @@ -722,8 +723,13 @@ function setup_graph_view_buttons() { } // function to setup collapse buttons and icons -function setup_collapsables(elementToSearch = document) { - elementToSearch.querySelectorAll(".collapse-icon").forEach(icon => { +function setup_collapsables() { + document.querySelectorAll(".collapse-icon").forEach(origIcon => { + // Replace the element with a clone to remove existing listeners + // required to readd collapsables for overview project sections + const icon = origIcon.cloneNode(true); + origIcon.replaceWith(icon); + const sectionId = icon.id.replace("collapse", ""); const update_icon = () => { const section = document.getElementById(sectionId); @@ -748,11 +754,73 @@ function attach_run_card_version_listener(versionElement, projectName, projectVe }); } +function setup_overview_order_filters() { + const parseProjectId = (selectId) => selectId.replace(/SectionOrder$/i, ""); + const parseRunStatsFromCard = (cardEl) => { + const text = cardEl.innerText || ""; + const runMatch = text.match(/#\s*(\d+)/); // e.g., "#8" + const passedMatch = text.match(/Passed:\s*(\d+)/i); + const failedMatch = text.match(/Failed:\s*(\d+)/i); + const skippedMatch = text.match(/Skipped:\s*(\d+)/i); + return { + runNumber: runMatch ? parseInt(runMatch[1]) : 0, + passed: passedMatch ? parseInt(passedMatch[1]) : 0, + failed: failedMatch ? parseInt(failedMatch[1]) : 0, + skipped: skippedMatch ? parseInt(skippedMatch[1]) : 0, + }; + }; + + const reorderProjectCards = (projectId, order) => { + // Determine correct container for both overview and project sections + const containerId = `${projectId}RunCardsContainer`; + const container = document.getElementById(containerId); + if (!container) return; // guard against missing containers + const cards = Array.from(container.querySelectorAll('.overview-card')); + if (cards.length === 0) return; + const enriched = cards.map(card => ({ el: card, stats: parseRunStatsFromCard(card) })); + + const cmpDesc = (a, b, key) => (b.stats[key] - a.stats[key]); + const cmpAsc = (a, b, key) => (a.stats[key] - b.stats[key]); + + if (order === "oldest" || order.toLowerCase() === "oldest run") { + enriched.sort((a, b) => cmpAsc(a, b, 'runNumber')); + } else if (order === "most failed") { + enriched.sort((a, b) => cmpDesc(a, b, 'failed')); + } else if (order === "most skipped") { + enriched.sort((a, b) => cmpDesc(a, b, 'skipped')); + } else if (order === "most passed") { + enriched.sort((a, b) => cmpDesc(a, b, 'passed')); + } else { + enriched.sort((a, b) => cmpDesc(a, b, 'runNumber')); + } + const fragment = document.createDocumentFragment(); + enriched.forEach(item => fragment.appendChild(item.el)); + container.innerHTML = ''; + container.appendChild(fragment); + }; + + document.querySelectorAll('.section-order-filter').forEach(select => { + const selectId = select.id; + if (selectId === "overviewStatisticsSectionOrder") { + select.addEventListener('change', (e) => { + create_overview_statistics_graphs(); + }); + } else { + const projectId = parseProjectId(selectId); + select.addEventListener('change', (e) => { + const order = (e.target.value || '').toLowerCase(); + reorderProjectCards(projectId, order); + }); + } + }); +} + export { setup_filter_modal, setup_settings_modal, setup_sections_filters, setup_graph_view_buttons, setup_collapsables, - attach_run_card_version_listener + attach_run_card_version_listener, + setup_overview_order_filters }; \ No newline at end of file diff --git a/robotframework_dashboard/js/graph_creation/all.js b/robotframework_dashboard/js/graph_creation/all.js index 8bdcbeb..8084245 100644 --- a/robotframework_dashboard/js/graph_creation/all.js +++ b/robotframework_dashboard/js/graph_creation/all.js @@ -58,7 +58,7 @@ import { function setup_dashboard_graphs() { if (settings.menu.overview) { create_overview_statistics_graphs(); - update_donut_charts("projectOverviewData"); + update_donut_charts(); } else if (settings.menu.dashboard) { create_run_statistics_graph(); create_run_donut_graph(); diff --git a/robotframework_dashboard/js/graph_creation/overview.js b/robotframework_dashboard/js/graph_creation/overview.js index 011b6af..5f5d73a 100644 --- a/robotframework_dashboard/js/graph_creation/overview.js +++ b/robotframework_dashboard/js/graph_creation/overview.js @@ -7,7 +7,6 @@ import { import { update_menu } from '../menu.js'; import { setup_collapsables, - setup_filter_checkbox_handler_listeners, attach_run_card_version_listener } from '../eventlisteners.js'; import { clockDarkSVG, clockLightSVG, arrowRight } from '../variables/svg.js'; @@ -32,10 +31,11 @@ import { areGroupedProjectsPrepared } from '../variables/globals.js'; import { runs, use_logs } from '../variables/data.js'; -import { clear_all_filters, update_filter_active_indicator } from '../filter.js'; +import { clear_all_filters, update_filter_active_indicator, setup_filter_checkbox_handler_listeners } from '../filter.js'; // function to create overview statistics blocks in the overview section function create_overview_statistics_graphs(preFilteredRuns = null) { + const order = document.getElementById("overviewStatisticsSectionOrder").value; const overviewCardsContainer = document.getElementById("overviewRunCardsContainer"); overviewCardsContainer.innerHTML = ''; const allProjects = { ...projects_by_name, ...projects_by_tag }; @@ -44,13 +44,32 @@ function create_overview_statistics_graphs(preFilteredRuns = null) { const durations = projectRuns.map(r => r.elapsed_s); durationsByProject[projectName] = durations; } - const latestRunByProject = {}; + let latestRunByProject = {}; if (!preFilteredRuns) { settings.switch.runName && Object.assign(latestRunByProject, latestRunByProjectName); settings.switch.runTags && Object.assign(latestRunByProject, latestRunByProjectTag); } else { // if called by version filter listener Object.assign(latestRunByProject, preFilteredRuns); } + // default order by newest (keep current insertion order) + if (order === 'oldest') { + // Reverse current order while preserving the same key->value pairs + latestRunByProject = Object.fromEntries( + Object.entries(latestRunByProject).reverse() + ); + } else if (order === 'most failed') { + latestRunByProject = Object.fromEntries( + Object.entries(latestRunByProject).sort(([, runA], [, runB]) => runB.failed - runA.failed) + ); + } else if (order === 'most skipped') { + latestRunByProject = Object.fromEntries( + Object.entries(latestRunByProject).sort(([, runA], [, runB]) => runB.skipped - runA.skipped) + ); + } else if (order === 'most passed') { + latestRunByProject = Object.fromEntries( + Object.entries(latestRunByProject).sort(([, runA], [, runB]) => runB.passed - runA.passed) + ); + } for (const [projectName, latestRun] of Object.entries(latestRunByProject)) { const projectRuns = allProjects[projectName]; const totalRunsAmount = projectRuns.length; @@ -71,9 +90,8 @@ function create_overview_statistics_graphs(preFilteredRuns = null) { } } -function update_donut_charts(scopeElement) { - const donutContainer = document.getElementById(scopeElement); - donutContainer.querySelectorAll(".overview-canvas").forEach(canvas => { +function update_donut_charts() { + document.querySelectorAll(".overview-canvas").forEach(canvas => { const chart = canvas.querySelector("canvas").chartInstance; if (chart) chart.update(); }); @@ -123,15 +141,13 @@ function prepare_projects_grouped_data() { } function create_project_overview() { - const projectOverviewData = document.getElementById("projectOverviewData"); - projectOverviewData.innerHTML = ""; const projectData = { ...projects_by_name, ...projects_by_tag }; // create run cards for each project Object.keys(projectData).sort().forEach(projectName => { create_project_cards_container(projectName, projectData[projectName]); }); // setup collapsables specifically for overview project sections - setup_collapsables(projectOverviewData); + setup_collapsables(); // set project bar visibility based on switch settings update_projectbar_visibility(); } @@ -164,7 +180,7 @@ function create_project_bar(projectName, projectRuns, totalRunsAmount, passRate) - Clicking on the run card applies a filter for that project and switches to dashboard - Clicking on the version element within the run card additionally applies a filter for that version`; const projectCard = ` -
+
@@ -187,7 +203,7 @@ function create_project_bar(projectName, projectRuns, totalRunsAmount, passRate)
-
+
+
+
+ +
+
+ +
+
+
+ + +
@@ -216,7 +250,8 @@ function create_project_bar(projectName, projectRuns, totalRunsAmount, passRate)
`; - projectOverviewData.appendChild(document.createRange().createContextualFragment(projectCard)); + const overview = document.getElementById("overview") + overview.appendChild(document.createRange().createContextualFragment(projectCard)); // percentage selector const projectPercentageSelector = document.getElementById(`${projectName}DurationPercentage`); projectPercentageSelector.addEventListener('change', () => { @@ -374,9 +409,7 @@ function create_overview_run_donut(run, chartElementPostfix, projectName) { // hide project bars based on switch config function update_projectbar_visibility() { - const container = document.getElementById("projectOverviewData"); - if (!container) return; - const bars = container.querySelectorAll('[id$="Card"]'); + const bars = document.querySelectorAll('.overview-project-card'); const tagged = []; const untagged = []; for (const el of bars) { diff --git a/robotframework_dashboard/js/layout.js b/robotframework_dashboard/js/layout.js index 6544830..1fc1ac9 100644 --- a/robotframework_dashboard/js/layout.js +++ b/robotframework_dashboard/js/layout.js @@ -21,40 +21,76 @@ function setup_section_order() { document.getElementById("dashboard").hidden = !(settings.menu.dashboard && !settings.show.unified); document.getElementById("compare").hidden = !settings.menu.compare; document.getElementById("tables").hidden = !settings.menu.tables; - let prevId = "#topSection"; - - for (const section of settings.view.dashboard.sections.show) { - const sectionId = space_to_camelcase(section + "Section"); - const sectionEl = document.getElementById(sectionId); - sectionEl.hidden = false; - $(`#${sectionId}`).insertAfter(prevId); - prevId = `#${sectionId}`; - } - for (const section of settings.view.dashboard.sections.hide) { - const sectionId = space_to_camelcase(section + "Section"); - const sectionEl = document.getElementById(sectionId); - if (gridEditMode) { - sectionEl.hidden = false; + const order_sections = (sectionsConfig, topAnchorId) => { + let prevId = `#${topAnchorId}`; + // Show + for (const section of sectionsConfig.show) { + let sectionId; + if (section === "Overview Statistics" || topAnchorId !== "topOverviewSection") { + // 1. Keep overview statistics as-is (camel-cased id) + sectionId = space_to_camelcase(section + "Section"); + } else { + // 2. For overview non-defaults, use raw id pattern: section+"Section" + sectionId = section + "Section"; + } + const sectionEl = document.getElementById(sectionId); + if (!sectionEl) continue; + if (topAnchorId === "topDashboardSection") { + sectionEl.hidden = false; + } $(`#${sectionId}`).insertAfter(prevId); prevId = `#${sectionId}`; - } else { - sectionEl.hidden = true; } - } + // Hide + for (const section of sectionsConfig.hide) { + const sectionId = space_to_camelcase(section + "Section"); + const sectionEl = document.getElementById(sectionId); + if (!sectionEl) continue; + if (gridEditMode) { + sectionEl.hidden = false; + $(`#${sectionId}`).insertAfter(prevId); + prevId = `#${sectionId}`; + } else { + sectionEl.hidden = true; + } + } + }; + + order_sections(settings.view.dashboard.sections, "topDashboardSection"); + order_sections(settings.view.overview.sections, "topOverviewSection"); + + // expand only the top section in the overview page + const overviewBars = document.querySelectorAll("#overview .overview-bar"); + overviewBars.forEach((bar, i) => { + const btn = bar.querySelector(".collapse-icon"); + const isExpanded = !!btn.querySelector(".lucide-chevron-down-icon"); // ▼ + const isCollapsed = !!btn.querySelector(".lucide-chevron-right-icon"); // ▶ + if (i === 0) { + if (isCollapsed) { + btn.click(); + } + return; + } + if (isExpanded) { + btn.click(); + } + }); + if (gridEditMode) { document.querySelectorAll(".move-up-section").forEach(btn => { btn.hidden = false }) document.querySelectorAll(".move-down-section").forEach(btn => { btn.hidden = false }) document.querySelectorAll(".shown-section, .hidden-section").forEach(btn => { const prefix = btn.id.slice(0, 3); - const label = prefix.charAt(0).toUpperCase() - var shouldShow = false; - for (const section of settings.view.dashboard.sections.show) { - if (section.startsWith(label)) { - shouldShow = true; - break - } - } + const label = prefix.charAt(0).toUpperCase(); + // Decide context: dashboard or overview based on the containing section/card + const isOverview = !!btn.closest('#overview'); + const showList = isOverview + ? settings.view.overview.sections.show + : settings.view.dashboard.sections.show; + + let shouldShow = showList.some(section => section.startsWith(label)); + if (btn.classList.contains("shown-section")) { btn.hidden = !shouldShow; } else if (btn.classList.contains("hidden-section")) { @@ -338,13 +374,13 @@ function save_layout() { set_local_storage_item("view.tables.graphs.hide", hiddenTables) } // save dashboard section layout - const shownDashboardSections = [...document.querySelectorAll(".shown-section:not([hidden])")] + const shownDashboardSections = [...document.querySelectorAll("#dashboard .shown-section:not([hidden])")] .map(el => { var key = el.id.replace("SectionShown", ""); key = String(key).charAt(0).toUpperCase() + String(key).slice(1); return `${key} Statistics` }); - const hiddenDashboardSections = [...document.querySelectorAll(".hidden-section:not([hidden])")] + const hiddenDashboardSections = [...document.querySelectorAll("#dashboard .hidden-section:not([hidden])")] .map(el => { var key = el.id.replace("SectionHidden", ""); key = String(key).charAt(0).toUpperCase() + String(key).slice(1); @@ -354,6 +390,17 @@ function save_layout() { set_local_storage_item("view.dashboard.sections.show", shownDashboardSections) set_local_storage_item("view.dashboard.sections.hide", hiddenDashboardSections) } + // save overview section layout always shown + const shownOverviewSections = [...document.querySelectorAll("#overview .move-up-section")] + .map(el => { + if (el.id === "overviewSectionMoveUp") { + return "Overview Statistics" + } + return el.id.replace("SectionMoveUp", ""); + }); + if (shownOverviewSections.length > 0) { + set_local_storage_item("view.overview.sections.show", shownOverviewSections) + } add_alert("Layout has been updated and saved to settings in local storage!", "success") } @@ -384,52 +431,38 @@ function setup_edit_mode_icons(hidden) { } } -// function to add the layout eventlisteners -function setup_layout() { - document.addEventListener("graphs-finalized", () => { - if (gridEditMode) { - setup_edit_mode_icons(true); - } else { - setup_edit_mode_icons(false); - } - }); - document.getElementById("customizeLayout").addEventListener("click", (e) => { - gridEditMode = !gridEditMode; - customize_layout(); - setup_data_and_graphs(); - }); - document.getElementById("saveLayout").addEventListener("click", (e) => { - gridEditMode = !gridEditMode; - save_layout() - setup_data_and_graphs(); - }); - // disable or enable sections - document.querySelectorAll(".shown-section").forEach(btn => { +// Reusable handler to wire show/hide and move controls within a container +function attach_section_order_buttons(containerId) { + const root = `#${containerId}`; + // Toggle shown/hidden buttons + document.querySelectorAll(`${root} .shown-section`).forEach(btn => { btn.addEventListener("click", () => { btn.hidden = true; - document.getElementById(`${btn.id.replace("Shown", "Hidden")}`).hidden = false; - }) + const target = document.getElementById(`${btn.id.replace("Shown", "Hidden")}`); + if (target) target.hidden = false; + }); }); - document.querySelectorAll(".hidden-section").forEach(btn => { + document.querySelectorAll(`${root} .hidden-section`).forEach(btn => { btn.addEventListener("click", () => { btn.hidden = true; - document.getElementById(`${btn.id.replace("Hidden", "Shown")}`).hidden = false; - }) + const target = document.getElementById(`${btn.id.replace("Hidden", "Shown")}`); + if (target) target.hidden = false; + }); }); - // move sections up or down - document.querySelectorAll(".move-up-section").forEach(btn => { + // Move cards up/down within the container + document.querySelectorAll(`${root} .move-up-section`).forEach(btn => { btn.addEventListener("click", () => { const card = btn.closest(".card"); - const previousCard = card.previousElementSibling; + const previousCard = card?.previousElementSibling; if (previousCard && !previousCard.hidden && previousCard.classList.contains("card")) { card.parentNode.insertBefore(card, previousCard); } }); }); - document.querySelectorAll(".move-down-section").forEach(btn => { + document.querySelectorAll(`${root} .move-down-section`).forEach(btn => { btn.addEventListener("click", () => { const card = btn.closest(".card"); - const nextCard = card.nextElementSibling; + const nextCard = card?.nextElementSibling; if (nextCard && !nextCard.hidden && nextCard.classList.contains("card")) { card.parentNode.insertBefore(nextCard, card); } @@ -437,9 +470,38 @@ function setup_layout() { }); } +// function to add the layout eventlisteners +function setup_dashboard_section_layout_buttons() { + document.addEventListener("graphs-finalized", () => { + if (gridEditMode) { + setup_edit_mode_icons(true); + } else { + setup_edit_mode_icons(false); + } + }); + document.getElementById("customizeLayout").addEventListener("click", (e) => { + gridEditMode = !gridEditMode; + customize_layout(); + setup_data_and_graphs(); + }); + document.getElementById("saveLayout").addEventListener("click", (e) => { + gridEditMode = !gridEditMode; + save_layout() + setup_data_and_graphs(); + }); + attach_section_order_buttons("dashboard"); +} + +// function to separately add the eventlisteners for overview section layout buttons +// this happens on first render of overview section only +function setup_overview_section_layout_buttons() { + attach_section_order_buttons("overview"); +} + export { setup_section_order, setup_graph_order, setup_tables, - setup_layout, + setup_dashboard_section_layout_buttons, + setup_overview_section_layout_buttons, }; \ No newline at end of file diff --git a/robotframework_dashboard/js/localstorage.js b/robotframework_dashboard/js/localstorage.js index 2e98165..d4d9e20 100644 --- a/robotframework_dashboard/js/localstorage.js +++ b/robotframework_dashboard/js/localstorage.js @@ -1,6 +1,8 @@ import { add_alert } from "./common.js"; +import { overviewSections } from "./variables/graphs.js"; import { settings } from "./variables/settings.js"; import { force_json_config, json_config, admin_json_config } from "./variables/data.js"; +import { projects_by_name, projects_by_tag } from "./variables/globals.js"; // function to setup localstorage on first load function setup_local_storage() { @@ -127,7 +129,8 @@ function merge_view(localView, defaultView) { result[page] = { sections: merge_view_section_or_graph( localPage.sections || {}, - defaultPage.sections + defaultPage.sections, + page ), graphs: merge_view_section_or_graph( localPage.graphs || {}, @@ -139,8 +142,9 @@ function merge_view(localView, defaultView) { } // function to merge view sections or graphs from localstorage with defaults from settings -function merge_view_section_or_graph(local, defaults) { +function merge_view_section_or_graph(local, defaults, page = null) { const result = { show: [], hide: [] }; + const isOverview = page === "overview"; const allowed = new Set([ ...defaults.show, ...defaults.hide @@ -148,11 +152,17 @@ function merge_view_section_or_graph(local, defaults) { const localShow = new Set(local.show || []); const localHide = new Set(local.hide || []); // 1. Remove values not in defaults (allowed) + // For overview, preserve additional dynamic items in SHOW (added later), + // but still clean up HIDE to avoid hiding unknown entries. for (const val of [...localShow]) { - if (!allowed.has(val)) localShow.delete(val); + if (!allowed.has(val) && !isOverview) { + localShow.delete(val); + } } for (const val of [...localHide]) { - if (!allowed.has(val)) localHide.delete(val); + if (!allowed.has(val)) { + localHide.delete(val); + } } // 2. Add missing defaults: always added to SHOW for (const val of allowed) { @@ -229,10 +239,30 @@ function update_graph_type(graph, type) { set_local_storage_item('graphTypes', settings.graphTypes); } +// function to setup the overview sections that are dynamically created +function setup_overview_localstorage() { + if (Object.keys(projects_by_name).length > 0) { + Object.keys(projects_by_name).forEach(projectName => { + overviewSections.push(projectName) + }); + } + if (Object.keys(projects_by_tag).length > 0) { + Object.keys(projects_by_tag).forEach(tagName => { + overviewSections.push(tagName) + }); + } + // on first load without localstorage only overview sections is present + // if more items are available, set them in localstorage, previous order is lost + if (settings.view.overview.sections.show.length < overviewSections.length) { + set_local_storage_item("view.overview.sections.show", overviewSections) + } +} + export { setup_local_storage, set_local_storage_item, set_nested_setting, update_switch_local_storage, - update_graph_type + update_graph_type, + setup_overview_localstorage }; \ No newline at end of file diff --git a/robotframework_dashboard/js/main.js b/robotframework_dashboard/js/main.js index 8da9c45..f46f3bc 100644 --- a/robotframework_dashboard/js/main.js +++ b/robotframework_dashboard/js/main.js @@ -1,6 +1,6 @@ import { setup_local_storage } from "./localstorage.js"; import { setup_database_stats } from "./database.js"; -import { setup_layout } from "./layout.js"; +import { setup_dashboard_section_layout_buttons } from "./layout.js"; import { setup_sections_filters, setup_collapsables, @@ -14,7 +14,7 @@ import { setup_menu } from "./menu.js"; function main() { setup_local_storage(); setup_database_stats(); - setup_layout(); + setup_dashboard_section_layout_buttons(); setup_sections_filters(); setup_collapsables(); setup_filter_modal(); diff --git a/robotframework_dashboard/js/menu.js b/robotframework_dashboard/js/menu.js index 2547517..e11668a 100644 --- a/robotframework_dashboard/js/menu.js +++ b/robotframework_dashboard/js/menu.js @@ -1,19 +1,87 @@ import { setup_filtered_data_and_filters, update_overview_version_select_list } from "./filter.js"; import { areGroupedProjectsPrepared } from "./variables/globals.js"; import { space_to_camelcase } from "./common.js"; -import { set_local_storage_item } from "./localstorage.js"; +import { set_local_storage_item, setup_overview_localstorage } from "./localstorage.js"; import { setup_dashboard_graphs } from "./graph_creation/all.js"; import { settings } from "./variables/settings.js"; import { setup_theme } from "./theme.js"; -import { setup_graph_view_buttons } from "./eventlisteners.js"; -import { setup_section_order, setup_graph_order } from "./layout.js"; +import { setup_graph_view_buttons, setup_overview_order_filters } from "./eventlisteners.js"; +import { setup_section_order, setup_graph_order, setup_overview_section_layout_buttons } from "./layout.js"; import { setup_information_popups } from "./information.js"; import { update_overview_statistics_heading, prepare_overview } from "./graph_creation/overview.js"; +// Track overview nav listeners so we can cleanly remove them when leaving Overview +let __overviewNavStore = { + scrollHandler: null, + resizeHandler: null, +}; + +// ---- Shared helpers for menu buttons ---- +function get_sticky_height() { + const stickyTop = document.getElementById("navigation"); + return stickyTop ? stickyTop.offsetHeight : 0; +} + +function expand_and_scroll_to(targetEl) { + const stickyHeight = get_sticky_height(); + const performScroll = () => { + const targetTop = targetEl.getBoundingClientRect().top + window.pageYOffset; + const top = targetTop < 200 ? 0 : targetTop - stickyHeight - 8; + window.scrollTo({ top: top - 7, behavior: "auto" }); + }; + const collapseBtn = targetEl.querySelector(".collapse-icon"); + if (collapseBtn) { + const svg = collapseBtn.querySelector("svg"); + const isExpanded = svg && (svg.classList.contains("lucide-chevron-down-icon") || svg.classList.contains("lucide-chevron-down")); + if (!isExpanded) { + collapseBtn.click(); + requestAnimationFrame(() => setTimeout(performScroll, 50)); + return; + } + } + performScroll(); +} + +function compute_best_visible_index(sections) { + const viewportHeight = window.innerHeight; + const viewportTop = viewportHeight * 0.2; + const viewportBottom = viewportHeight * 0.5; + let bestIndex = 0; + let bestAmount = -Infinity; + sections.forEach((section, idx) => { + const rect = section.getBoundingClientRect(); + const top = Math.max(rect.top, viewportTop); + const bottom = Math.min(rect.bottom, viewportBottom); + const overlap = bottom - top; + if (overlap > bestAmount) { + bestAmount = overlap; + bestIndex = idx; + } + }); + return bestIndex; +} + +function neighbor_indices(bestIndex, length) { + const indices = []; + const pushIfValid = (i) => { if (i >= 0 && i < length) indices.push(i); }; + if (bestIndex <= 0) { + pushIfValid(0); pushIfValid(1); pushIfValid(2); + } else if (bestIndex >= length - 1) { + pushIfValid(length - 3); + pushIfValid(length - 2); + pushIfValid(length - 1); + } else { + pushIfValid(bestIndex - 1); + pushIfValid(bestIndex); + pushIfValid(bestIndex + 1); + } + return indices; +} + // function to update the section (menu) buttons with the correct eventlisteners // also sets up the automatic highlighting of the section that is most visible in the top // 20-50% percent of the screen -function setup_section_menu_buttons() { +function setup_dashboard_section_menu_buttons() { const sectionButtons = [ document.getElementById("runStatisticsSectionNav"), document.getElementById("suiteStatisticsSectionNav"), @@ -37,23 +105,8 @@ function setup_section_menu_buttons() { const sections = Object.keys(sectionMap).map(id => document.getElementById(id)); function update_active_section() { - const viewportHeight = window.innerHeight; - const viewportTop = viewportHeight * 0.2; - const viewportBottom = viewportHeight * 0.5; - let bestMatch = null; - let bestMatchAmount = 0; - sections.forEach(section => { - const rect = section.getBoundingClientRect(); - // Calculate overlap in the 20%-50% viewport vertical range - const top = Math.max(rect.top, viewportTop); - const bottom = Math.min(rect.bottom, viewportBottom); - const overlap = bottom - top; - if (overlap > bestMatchAmount) { - bestMatch = section; - bestMatchAmount = overlap; - } - }); - + const bestIndex = compute_best_visible_index(sections); + const bestMatch = sections[bestIndex]; // Highlight the matching button sectionButtons.forEach(btn => btn.classList.remove("active")); if (bestMatch && sectionMap[bestMatch.id]) { @@ -69,17 +122,165 @@ function setup_section_menu_buttons() { btn.addEventListener("click", () => { const target = document.getElementById(btn.id.slice(0, -3)); if (target) { + expand_and_scroll_to(target); + } + }); + }); +} + +// function to create and manage overview section buttons that highlight dynamically +// Shows at most 3 buttons: the most visible section in the 20%-50% viewport band +// plus one above and one below it. At edges, show first or last 3 accordingly. +function setup_overview_section_menu_buttons() { + // Only render when Overview menu is active; otherwise remove any existing dynamic buttons + const isOverviewActive = !!(settings.menu && settings.menu.overview); + const navbar = document.querySelector(".navbar-nav"); + const overviewMenuLink = document.getElementById("menuOverview"); + if (!navbar || !overviewMenuLink) return; + + // Cleanup any previously created overview dynamic buttons + const existingOverviewButtons = Array.from(navbar.querySelectorAll('a[id^="overview-"][id$="Nav"]')); + if (!isOverviewActive) { + existingOverviewButtons.forEach(el => el.remove()); + // Detach listeners if still attached + if (__overviewNavStore.scrollHandler) { + window.removeEventListener("scroll", __overviewNavStore.scrollHandler); + __overviewNavStore.scrollHandler = null; + } + if (__overviewNavStore.resizeHandler) { + window.removeEventListener("resize", __overviewNavStore.resizeHandler); + __overviewNavStore.resizeHandler = null; + } + return; + } + + const sections = Array.from(document.querySelectorAll("#overview .overview-bar")) + .filter(el => el.offsetParent !== null); + if (sections.length === 0) return; + + // Helper to insert after Overview menu item + const insertAfter = (newNode, referenceNode) => { + if (referenceNode.nextSibling) { + referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling); + } else { + referenceNode.parentNode.appendChild(newNode); + } + }; + + // Build a map of sectionId -> button (anchor) in navbar + const buttonMap = new Map(); + const makeButtonForSection = (sectionEl) => { + const sectionId = sectionEl.id || ""; + let baseName = sectionId.replace(/Section$/i, ""); + if (baseName === "overviewStatistics") baseName = "Overview Statistics"; + const btnId = `overview-${sectionId}Nav`; + let btn = document.getElementById(btnId); + if (!btn) { + btn = document.createElement("a"); + btn.id = btnId; + btn.className = "nav-item nav-link"; + const label = document.createElement("i"); + label.textContent = baseName; + btn.appendChild(label); + insertAfter(btn, overviewMenuLink); + + btn.addEventListener("click", (e) => { + e.preventDefault(); const stickyTop = document.getElementById("navigation"); const stickyHeight = stickyTop ? stickyTop.offsetHeight : 0; - const targetTop = target.getBoundingClientRect().top + window.pageYOffset; - const top = targetTop < 200 ? 0 : targetTop - stickyHeight - 8; // exception for the section at the top, scroll to 0 - window.scrollTo({ - top: top - 7, - behavior: "auto" - }); + + const performScroll = () => { + const targetTop = sectionEl.getBoundingClientRect().top + window.pageYOffset; + const top = targetTop < 200 ? 0 : targetTop - stickyHeight - 8; + window.scrollTo({ top: top - 7, behavior: "auto" }); + }; + + // Expand the section if it is currently collapsed, then scroll + const collapseBtn = sectionEl.querySelector(".collapse-icon"); + if (collapseBtn) { + const svg = collapseBtn.querySelector("svg"); + const isExpanded = svg && (svg.classList.contains("lucide-chevron-down-icon") || svg.classList.contains("lucide-chevron-down")); + if (!isExpanded) { + // Trigger expansion and wait a tick for layout update + collapseBtn.click(); + requestAnimationFrame(() => setTimeout(performScroll, 50)); + return; + } + } + performScroll(); + }); + } + buttonMap.set(sectionId, btn); + }; + + sections.forEach(makeButtonForSection); + + // Before attaching new listeners, remove any previous ones to avoid stale closures + if (__overviewNavStore.scrollHandler) { + window.removeEventListener("scroll", __overviewNavStore.scrollHandler); + __overviewNavStore.scrollHandler = null; + } + if (__overviewNavStore.resizeHandler) { + window.removeEventListener("resize", __overviewNavStore.resizeHandler); + __overviewNavStore.resizeHandler = null; + } + + const updateVisibleButtons = () => { + // If not on overview anymore, cleanup and stop + const stillOverview = !!(settings.menu && settings.menu.overview); + const showButtons = stillOverview; + if (!stillOverview) { + const toRemove = Array.from(navbar.querySelectorAll('a[id^="overview-"][id$="Nav"]')); + toRemove.forEach(el => el.remove()); + if (__overviewNavStore.scrollHandler) { + window.removeEventListener("scroll", __overviewNavStore.scrollHandler); + __overviewNavStore.scrollHandler = null; + } + if (__overviewNavStore.resizeHandler) { + window.removeEventListener("resize", __overviewNavStore.resizeHandler); + __overviewNavStore.resizeHandler = null; } + return; + } + // Determine most visible section and neighboring indices + const bestIndex = compute_best_visible_index(sections); + const indices = neighbor_indices(bestIndex, sections.length); + + // Desired left-to-right order: highest (above) on the left, then current, then below + const desiredOrder = indices.slice(); // already in [best-1, best, best+1] or edges + + // Reorder buttons in the navbar immediately after the Overview link + let last = overviewMenuLink; + desiredOrder.forEach(idx => { + const section = sections[idx]; + const btn = buttonMap.get(section.id); + if (!btn) return; + // Ensure visibility before positioning + btn.hidden = !showButtons ? true : false; + // Move button to desired position + if (last.nextSibling === btn) { + // Already in place + } else { + insertAfter(btn, last); + } + last = btn; }); - }); + + // Update active state and hide non-selected buttons + sections.forEach((section, idx) => { + const btn = buttonMap.get(section.id); + if (!btn) return; + const shouldShow = showButtons && desiredOrder.includes(idx); + btn.hidden = !shouldShow; + btn.classList.toggle("active", showButtons && idx === bestIndex); + }); + }; + + window.addEventListener("scroll", updateVisibleButtons, { passive: true }); + window.addEventListener("resize", updateVisibleButtons); + __overviewNavStore.scrollHandler = updateVisibleButtons; + __overviewNavStore.resizeHandler = updateVisibleButtons; + updateVisibleButtons(); } function get_most_visible_section() { @@ -129,9 +330,8 @@ function update_menu(item) { }); ["menuOverview", "menuDashboard", "menuCompare", "menuTables"].forEach(id => { document.getElementById(id).classList.toggle("active", id === item); - document.getElementById("customizeLayout").hidden = item == "menuOverview"; - document.getElementById("projectOverview").hidden = item !== "menuOverview"; }); + document.getElementById("filters").hidden = (item === "menuOverview"); setup_data_and_graphs(true, item === "menuOverview" && !areGroupedProjectsPrepared); } @@ -189,6 +389,9 @@ function setup_data_and_graphs(menuUpdate = false, prepareOverviewProjectData = requestAnimationFrame(() => { if (prepareOverviewProjectData) { prepare_overview(); + setup_overview_localstorage(); + setup_overview_section_layout_buttons(); + setup_overview_order_filters(); update_overview_version_select_list(); update_overview_statistics_heading(); } @@ -203,7 +406,8 @@ function setup_data_and_graphs(menuUpdate = false, prepareOverviewProjectData = // then load the graphs requestAnimationFrame(() => { setup_spinner(true); - setup_section_menu_buttons(); // sections have to be visible to update highlighting correctly + setup_dashboard_section_menu_buttons(); + setup_overview_section_menu_buttons(); setup_dashboard_graphs(); document.dispatchEvent(new Event("graphs-finalized")); @@ -231,14 +435,12 @@ function setup_spinner(hide) { // Instant transition - hide spinner and show all content immediately $("#loading").fadeOut(200); $("#overview").fadeIn(200); - $("#projectOverview").fadeIn(200); $("#unified").fadeIn(200); $("#dashboard").fadeIn(200); $("#compare").fadeIn(200); $("#tables").fadeIn(200); } else { $("#overview").hide() - $("#projectOverview").hide() $("#unified").hide() $("#dashboard").hide() $("#compare").hide() @@ -251,5 +453,6 @@ export { setup_menu, setup_data_and_graphs, setup_spinner, - update_menu + update_menu, + setup_overview_section_menu_buttons }; \ No newline at end of file diff --git a/robotframework_dashboard/js/variables/graphs.js b/robotframework_dashboard/js/variables/graphs.js index e0689a7..cb4a8c3 100644 --- a/robotframework_dashboard/js/variables/graphs.js +++ b/robotframework_dashboard/js/variables/graphs.js @@ -34,7 +34,7 @@ graphVars.forEach(name => { const compareRunIds = ['compareRun1', 'compareRun2', 'compareRun3', 'compareRun4'] // customize view lists -const overviewSections = ["Overview"] +const overviewSections = ["Overview Statistics"] const dashboardSections = ["Run Statistics", "Suite Statistics", "Test Statistics", "Keyword Statistics",] const unifiedSections = ["Dashboard Statistics"] const compareSections = ["Compare Statistics"] diff --git a/robotframework_dashboard/templates/dashboard.html b/robotframework_dashboard/templates/dashboard.html index ec992ea..e644845 100644 --- a/robotframework_dashboard/templates/dashboard.html +++ b/robotframework_dashboard/templates/dashboard.html @@ -90,7 +90,8 @@ -