From c674be842f928c9593508391526ef7aab6a517c8 Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Wed, 11 Mar 2026 00:06:36 +0100 Subject: [PATCH 1/8] feat: add custom branding options for title and logo in dashboard - Introduced a new branding section in settings to allow custom title and logo. - Updated theme.js to apply custom branding when the theme is set up. - Enhanced dashboard.html to include input fields for custom title and logo upload. - Added a hamburger menu icon for navigation overflow and an icon tray for additional icons. --- robotframework_dashboard/css/base.css | 52 ++++++ robotframework_dashboard/js/eventlisteners.js | 40 ++++- robotframework_dashboard/js/main.js | 3 +- robotframework_dashboard/js/menu.js | 149 +++++++++++++++++- robotframework_dashboard/js/theme.js | 36 ++++- .../js/variables/settings.js | 4 + robotframework_dashboard/js/variables/svg.js | 2 + .../templates/dashboard.html | 38 ++++- 8 files changed, 316 insertions(+), 8 deletions(-) diff --git a/robotframework_dashboard/css/base.css b/robotframework_dashboard/css/base.css index 1526e53..c7eceff 100644 --- a/robotframework_dashboard/css/base.css +++ b/robotframework_dashboard/css/base.css @@ -215,3 +215,55 @@ body.lock-scroll { height: 24px; display: block; } + +/* ── Navbar overflow: hamburger dropdown ── */ +#navHamburgerMenu > a { + display: block; + padding: 0.35rem 1rem; + color: var(--color-menu-text); + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} +#navHamburgerMenu > a:hover, +#navHamburgerMenu > a.active { + background-color: var(--bs-dropdown-link-hover-bg, rgba(0, 0, 0, 0.06)); +} + +/* ── Navbar overflow: icon tray popup ── */ +.icon-tray-popup { + position: fixed; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 2px; + padding: 4px 6px; + list-style: none; + margin: 0; + background: var(--color-card); + border: 1px solid var(--bs-border-color); + border-radius: 6px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3); + z-index: 1040; + max-width: 300px; +} +.icon-tray-popup .nav-link svg { + width: 24px; + height: 24px; + display: block; +} + +/* ── Navbar overflow: custom title always on one line ── */ +#menuCustomTitle { + white-space: nowrap; +} + +/* ── Navbar overflow: abbreviated custom title ── */ +#menuCustomTitle.title-abbreviated { + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + vertical-align: middle; +} diff --git a/robotframework_dashboard/js/eventlisteners.js b/robotframework_dashboard/js/eventlisteners.js index 3026c6d..eddf5c9 100644 --- a/robotframework_dashboard/js/eventlisteners.js +++ b/robotframework_dashboard/js/eventlisteners.js @@ -14,7 +14,7 @@ import { } from "./variables/globals.js"; import { arrowDown, arrowRight } from "./variables/svg.js"; import { fullscreenButtons, graphChangeButtons, compareRunIds } from "./variables/graphs.js"; -import { toggle_theme, apply_theme_colors } from "./theme.js"; +import { toggle_theme, apply_theme_colors, apply_custom_branding } from "./theme.js"; import { add_alert, show_graph_loading, hide_graph_loading, update_graphs_with_loading, show_loading_overlay, hide_loading_overlay } from "./common.js"; import { setup_data_and_graphs, update_menu } from "./menu.js"; import { update_dashboard_graphs } from "./graph_creation/all.js"; @@ -414,6 +414,11 @@ function setup_settings_modal() { cardColorHandler.load_color(); highlightColorHandler.load_color(); textColorHandler.load_color(); + // Load branding state + document.getElementById('customBrandingTitle').value = settings.branding?.title || ""; + const hasLogo = !!settings.branding?.logo; + document.getElementById('removeCustomLogo').disabled = !hasLogo; + document.getElementById('customLogoUpload').value = ""; }); // Add event listeners for color inputs @@ -428,6 +433,39 @@ function setup_settings_modal() { document.getElementById('resetHighlightColor').addEventListener('click', () => highlightColorHandler.reset_color()); document.getElementById('resetTextColor').addEventListener('click', () => textColorHandler.reset_color()); + // Custom title handler + document.getElementById('customBrandingTitle').addEventListener('input', function () { + const title = this.value.trim(); + set_local_storage_item('branding.title', title); + apply_custom_branding(); + }); + + document.getElementById('clearCustomTitle').addEventListener('click', function () { + document.getElementById('customBrandingTitle').value = ""; + set_local_storage_item('branding.title', ""); + apply_custom_branding(); + }); + + // Custom logo handler + document.getElementById('customLogoUpload').addEventListener('change', function () { + const file = this.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = function (e) { + set_local_storage_item('branding.logo', e.target.result); + document.getElementById('removeCustomLogo').disabled = false; + apply_custom_branding(); + }; + reader.readAsDataURL(file); + }); + + document.getElementById('removeCustomLogo').addEventListener('click', function () { + set_local_storage_item('branding.logo', ""); + document.getElementById('customLogoUpload').value = ""; + this.disabled = true; + apply_custom_branding(); + }); + function show_settings_in_textarea() { const textArea = document.getElementById("settingsTextArea"); textArea.value = JSON.stringify(settings, null, 2); diff --git a/robotframework_dashboard/js/main.js b/robotframework_dashboard/js/main.js index f46f3bc..a3af872 100644 --- a/robotframework_dashboard/js/main.js +++ b/robotframework_dashboard/js/main.js @@ -7,7 +7,7 @@ import { setup_filter_modal, setup_settings_modal, } from "./eventlisteners.js"; -import { setup_menu } from "./menu.js"; +import { setup_menu, setup_navbar_overflow } from "./menu.js"; // function that triggers all functions that should be executed when the dashboard is loaded first // in the correct order! @@ -20,6 +20,7 @@ function main() { setup_filter_modal(); setup_settings_modal(); setup_menu(); + setup_navbar_overflow(); } Chart.register(ChartDataLabels); diff --git a/robotframework_dashboard/js/menu.js b/robotframework_dashboard/js/menu.js index 2622ace..a008b07 100644 --- a/robotframework_dashboard/js/menu.js +++ b/robotframework_dashboard/js/menu.js @@ -471,5 +471,150 @@ export { setup_data_and_graphs, setup_spinner, update_menu, - setup_overview_section_menu_buttons -}; \ No newline at end of file + setup_overview_section_menu_buttons, + setup_navbar_overflow +}; + +// ── Responsive navbar overflow ───────────────────────────────────────────── +// +// Level 0: everything visible in the navbar +// Level 1: page-menu items (Overview, Dashboard, …) collapse into a hamburger +// Level 2: icons collapse into a Windows-tray-style popup (↑ button) +// Level 3: custom title is abbreviated with ellipsis +// +function setup_navbar_overflow() { + const nav = document.getElementById('navigation'); + const mainNavDiv = document.getElementById('mainNavItems'); + const hamburgerWrap = document.getElementById('navHamburgerWrap'); + const hamburgerMenu = document.getElementById('navHamburgerMenu'); + const iconNavUl = document.getElementById('iconNavItems'); + const iconTrayWrap = document.getElementById('iconTrayWrap'); + const iconTrayToggle = document.getElementById('iconTrayToggle'); + const iconTrayPopup = document.getElementById('iconTrayPopup'); + const titleEl = document.getElementById('menuCustomTitle'); + + // IDs of the page-nav items that go into the hamburger at level 1 + const NAV_ITEM_IDS = [ + 'menuOverview', 'menuDashboard', + 'runStatisticsSectionNav', 'suiteStatisticsSectionNav', + 'testStatisticsSectionNav', 'keywordStatisticsSectionNav', + 'menuCompare', 'menuTables', 'openDashboard', + ]; + + // Capture icon
  • references once (they move, but references stay valid) + const iconLiEls = Array.from(iconNavUl.children).filter(li => li.id !== 'iconTrayWrap'); + + // State flags (idempotent guards) + let hamburgerActive = false; + let trayActive = false; + let titleAbbrev = false; + + // ── helpers ────────────────────────────────────────────────────────────── + + function nav_item_els() { + // Always resolved by ID so they're found wherever they currently live + return NAV_ITEM_IDS.map(id => document.getElementById(id)).filter(Boolean); + } + + function is_overflowing() { + // Bootstrap's .navbar has flex-wrap:wrap (nav grows taller instead of + // overflowing) and flex-shrink:1 on children (items shrink to near zero). + // Force a true single-row layout on all three containers for measurement. + const nfw = nav.style.flexWrap; + const mfs = mainNavDiv.style.flexShrink; + const ufw = iconNavUl.style.flexWrap; + const ufs = iconNavUl.style.flexShrink; + nav.style.flexWrap = 'nowrap'; + mainNavDiv.style.flexShrink = '0'; + iconNavUl.style.flexWrap = 'nowrap'; + iconNavUl.style.flexShrink = '0'; + const overflows = nav.scrollWidth > nav.clientWidth + 1; + nav.style.flexWrap = nfw; + mainNavDiv.style.flexShrink = mfs; + iconNavUl.style.flexWrap = ufw; + iconNavUl.style.flexShrink = ufs; + return overflows; + } + + // ── level transitions ──────────────────────────────────────────────────── + + function apply_hamburger(on) { + if (on === hamburgerActive) return; + hamburgerActive = on; + if (on) { + hamburgerWrap.hidden = false; + nav_item_els().forEach(el => hamburgerMenu.appendChild(el)); + } else { + nav_item_els().forEach(el => mainNavDiv.appendChild(el)); + hamburgerWrap.hidden = true; + } + } + + function apply_icon_tray(on) { + if (on === trayActive) return; + trayActive = on; + if (on) { + iconTrayWrap.hidden = false; + iconLiEls.forEach(li => iconTrayPopup.appendChild(li)); + } else { + iconTrayPopup.hidden = true; + iconLiEls.forEach(li => iconNavUl.insertBefore(li, iconTrayWrap)); + iconTrayWrap.hidden = true; + } + } + + function apply_title_abbrev(on) { + if (on === titleAbbrev || !titleEl) return; + titleAbbrev = on; + titleEl.classList.toggle('title-abbreviated', on); + } + + // ── core update ────────────────────────────────────────────────────────── + + function update_overflow() { + // Reset to level 0 for fresh measurement (idempotent helpers mean this + // is cheap when we're already at level 0) + apply_title_abbrev(false); + apply_icon_tray(false); + apply_hamburger(false); + + if (!is_overflowing()) return; // level 0 fits + apply_hamburger(true); + if (!is_overflowing()) return; // level 1 fits + apply_icon_tray(true); + if (!is_overflowing()) return; // level 2 fits + apply_title_abbrev(true); // level 3 + } + + // ── icon-tray popup positioning & toggle ───────────────────────────────── + + iconTrayToggle.addEventListener('click', (e) => { + e.stopPropagation(); + const nowHidden = !iconTrayPopup.hidden; + iconTrayPopup.hidden = nowHidden; + if (!nowHidden) { + const r = iconTrayToggle.getBoundingClientRect(); + iconTrayPopup.style.top = (r.bottom + 4) + 'px'; + iconTrayPopup.style.right = (window.innerWidth - r.right) + 'px'; + iconTrayPopup.style.left = ''; + } + }); + + document.addEventListener('click', (e) => { + if (!iconTrayPopup.hidden && !iconTrayPopup.contains(e.target)) { + iconTrayPopup.hidden = true; + } + }); + + // ── observe nav width ──────────────────────────────────────────────────── + + let debounce = null; + const ro = new ResizeObserver(() => { + clearTimeout(debounce); + debounce = setTimeout(update_overflow, 40); + }); + ro.observe(nav); + + // Initial run (after first paint so sizes are accurate) + requestAnimationFrame(update_overflow); +} \ No newline at end of file diff --git a/robotframework_dashboard/js/theme.js b/robotframework_dashboard/js/theme.js index 8c2a947..7d0972c 100644 --- a/robotframework_dashboard/js/theme.js +++ b/robotframework_dashboard/js/theme.js @@ -30,6 +30,7 @@ import { moveUpSVG, moveDownSVG, clockSVG, + menuSVG, } from "./variables/svg.js"; // function to update the theme when the button is clicked @@ -93,6 +94,8 @@ function setup_theme() { "bug": bugSVG(color), "customizeLayout": customizeViewSVG(color), "saveLayout": saveSVG(color), + "navHamburger": menuSVG(color), + "iconTrayToggle": moveUpSVG(color), }, classes: { ".percentage-graph": percentageSVG(color), @@ -151,6 +154,8 @@ function setup_theme() { // Apply custom theme colors if set apply_theme_colors(); + // Apply custom branding (logo and title) — must run after SVG map to override rflogo + apply_custom_branding(); } // function to apply custom theme colors @@ -197,8 +202,37 @@ function apply_theme_colors() { root.style.setProperty('--color-section-card-text', finalColors.text); } +// function to apply custom branding (logo and title) from settings / localStorage +function apply_custom_branding() { + // --- Custom title --- + const titleEl = document.getElementById("menuCustomTitle"); + const customTitle = settings.branding?.title || ""; + if (titleEl) { + if (customTitle) { + titleEl.textContent = customTitle; + titleEl.hidden = false; + } else { + titleEl.hidden = true; + } + } + + // --- Custom logo --- + const rflogoEl = document.getElementById("rflogo"); + const storedLogo = settings.branding?.logo; + if (rflogoEl) { + if (storedLogo) { + rflogoEl.innerHTML = `Logo`; + } else { + // Restore default RF logo (will be re-applied by setup_theme's SVG map) + const isDark = document.documentElement.classList.contains("dark-mode"); + rflogoEl.innerHTML = isDark ? getRflogoDarkSVG() : getRflogoLightSVG(); + } + } +} + export { toggle_theme, setup_theme, - apply_theme_colors + apply_theme_colors, + apply_custom_branding }; \ No newline at end of file diff --git a/robotframework_dashboard/js/variables/settings.js b/robotframework_dashboard/js/variables/settings.js index ddf4605..11fc70f 100644 --- a/robotframework_dashboard/js/variables/settings.js +++ b/robotframework_dashboard/js/variables/settings.js @@ -57,6 +57,10 @@ var settings = { dark: {}, } }, + branding: { + title: "", + logo: "", + }, menu: { overview: false, dashboard: true, diff --git a/robotframework_dashboard/js/variables/svg.js b/robotframework_dashboard/js/variables/svg.js index 7f153e4..dcadd3b 100644 --- a/robotframework_dashboard/js/variables/svg.js +++ b/robotframework_dashboard/js/variables/svg.js @@ -9,6 +9,7 @@ const pumpkin = ``; const earthGlobe = ``; const beerGlass = ``; +const menuSVG = (stroke) => ``; const filterSVG = (stroke) => `` const customizeViewSVG = (stroke) => `` const saveSVG = (stroke) => `` @@ -233,4 +234,5 @@ export { eyeOffSVG, moveDownSVG, moveUpSVG, + menuSVG, }; \ No newline at end of file diff --git a/robotframework_dashboard/templates/dashboard.html b/robotframework_dashboard/templates/dashboard.html index f1c8c1b..0c7aa32 100644 --- a/robotframework_dashboard/templates/dashboard.html +++ b/robotframework_dashboard/templates/dashboard.html @@ -19,8 +19,15 @@
    @@ -811,7 +824,6 @@

    Settings

    id="resetCardColor">Reset
    -
    Highlight Color
    @@ -829,6 +841,26 @@

    Settings

    +
    +
    + Custom Title +
    + + +
    +
    +
    + Custom Logo (PNG) +
    + + +
    From 1265d22580b1c73435847b0f76a4ac16cadbf51e Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Wed, 11 Mar 2026 21:20:02 +0100 Subject: [PATCH 2/8] feat: implement responsive sidebar navigation and update menu structure --- robotframework_dashboard/css/base.css | 140 ++-- robotframework_dashboard/js/menu.js | 609 ++++++++++-------- robotframework_dashboard/js/theme.js | 1 - .../templates/dashboard.html | 28 +- 4 files changed, 445 insertions(+), 333 deletions(-) diff --git a/robotframework_dashboard/css/base.css b/robotframework_dashboard/css/base.css index c7eceff..9c2e8be 100644 --- a/robotframework_dashboard/css/base.css +++ b/robotframework_dashboard/css/base.css @@ -208,6 +208,19 @@ body.lock-scroll { .navbar { margin-bottom: 1rem; + flex-wrap: nowrap !important; + overflow: hidden; +} + +#mainNavItems { + white-space: nowrap; + min-width: 0; + flex-shrink: 1; +} + +#iconNavItems { + flex-shrink: 0; + flex-wrap: nowrap !important; } #navigation .nav-link svg { @@ -216,54 +229,109 @@ body.lock-scroll { display: block; } -/* ── Navbar overflow: hamburger dropdown ── */ -#navHamburgerMenu > a { +/* ── Sidebar (off-canvas menu for overflowed items) ── */ +.sidenav-backdrop { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 1049; +} +.sidenav { + position: fixed; + top: 0; left: 0; bottom: 0; + width: 280px; + max-width: 85vw; + z-index: 1050; + background: var(--color-card); + border-right: 1px solid var(--bs-border-color); + box-shadow: 4px 0 20px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + transform: translateX(-100%); + transition: transform 0.25s ease; +} +.sidenav.sidenav-open { + transform: translateX(0); +} +.sidenav-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--bs-border-color); + flex-shrink: 0; +} +.sidenav-title { + font-weight: 600; + font-size: 1rem; + color: var(--color-text); +} +.sidenav-close { + background: none; + border: none; + font-size: 1.4rem; + line-height: 1; + color: var(--color-text); + cursor: pointer; + padding: 0 4px; +} +.sidenav-close:hover { + color: var(--color-highlight); +} +.sidenav-body { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} +.sidenav-section-label { + padding: 10px 16px 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: var(--color-text-muted); + letter-spacing: 0.05em; +} +.sidenav-body .sidenav-nav-item { display: block; - padding: 0.35rem 1rem; + padding: 6px 16px; color: var(--color-menu-text); text-decoration: none; - white-space: nowrap; cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-left: 3px solid transparent; } -#navHamburgerMenu > a:hover, -#navHamburgerMenu > a.active { - background-color: var(--bs-dropdown-link-hover-bg, rgba(0, 0, 0, 0.06)); +.sidenav-body .sidenav-nav-item:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--color-highlight); } - -/* ── Navbar overflow: icon tray popup ── */ -.icon-tray-popup { - position: fixed; - display: flex; - flex-direction: row; - flex-wrap: wrap; - gap: 2px; - padding: 4px 6px; - list-style: none; - margin: 0; - background: var(--color-card); - border: 1px solid var(--bs-border-color); - border-radius: 6px; - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3); - z-index: 1040; - max-width: 300px; +.sidenav-body .sidenav-nav-item.active { + border-left-color: var(--color-highlight); + color: var(--color-highlight); } -.icon-tray-popup .nav-link svg { - width: 24px; - height: 24px; +.sidenav-body .sidenav-nav-submenu { + padding-left: 32px; + font-style: italic; +} +.sidenav-icon-row { + display: grid; + grid-template-columns: repeat(3, auto); + gap: 4px; + padding: 6px 16px; + justify-content: start; +} +.sidenav-icon-row .nav-link { + padding: 6px; +} +.sidenav-icon-row .nav-link svg { + width: 22px; + height: 22px; display: block; } -/* ── Navbar overflow: custom title always on one line ── */ #menuCustomTitle { white-space: nowrap; -} - -/* ── Navbar overflow: abbreviated custom title ── */ -#menuCustomTitle.title-abbreviated { - max-width: 110px; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; - display: inline-block; - vertical-align: middle; } diff --git a/robotframework_dashboard/js/menu.js b/robotframework_dashboard/js/menu.js index a008b07..3df4b14 100644 --- a/robotframework_dashboard/js/menu.js +++ b/robotframework_dashboard/js/menu.js @@ -16,12 +16,14 @@ let __overviewNavStore = { 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 = () => { @@ -42,6 +44,7 @@ function expand_and_scroll_to(targetEl) { performScroll(); } + function compute_best_visible_index(sections) { const viewportHeight = window.innerHeight; const viewportTop = viewportHeight * 0.2; @@ -61,6 +64,7 @@ function compute_best_visible_index(sections) { return bestIndex; } + function neighbor_indices(bestIndex, length) { const indices = []; const pushIfValid = (i) => { if (i >= 0 && i < length) indices.push(i); }; @@ -78,6 +82,144 @@ function neighbor_indices(bestIndex, length) { return indices; } +// ---- Menu setup and navigation ---- + +function update_menu(item) { + ["overview", "dashboard", "compare", "tables"].forEach(menuItem => { + set_local_storage_item(`menu.${menuItem}`, (item === `menu${menuItem.charAt(0).toUpperCase() + menuItem.slice(1)}`)); + }); + ["menuOverview", "menuDashboard", "menuCompare", "menuTables"].forEach(id => { + document.getElementById(id).classList.toggle("active", id === item); + }); + document.getElementById("filters").hidden = (item === "menuOverview"); + setup_data_and_graphs(true, item === "menuOverview" && !areGroupedProjectsPrepared); +} + + +// function to setup the menu eventlisteners +function setup_menu() { + document.getElementById("menuOverview").addEventListener("click", () => update_menu("menuOverview")); + document.getElementById("menuDashboard").addEventListener("click", () => update_menu("menuDashboard")); + document.getElementById("menuCompare").addEventListener("click", () => update_menu("menuCompare")); + document.getElementById("menuTables").addEventListener("click", () => update_menu("menuTables")); + + const params = new URLSearchParams(window.location.search); + const pageParam = params.get("page"); + let selectedMenu; + + if (pageParam) { + switch (pageParam.toLowerCase()) { + case "overview": + selectedMenu = "menuOverview"; + break; + case "dashboard": + selectedMenu = "menuDashboard"; + break; + case "compare": + selectedMenu = "menuCompare"; + break; + case "tables": + selectedMenu = "menuTables"; + break; + } + if (selectedMenu) { + settings.menu = { + overview: selectedMenu === "menuOverview", + dashboard: selectedMenu === "menuDashboard", + compare: selectedMenu === "menuCompare", + tables: selectedMenu === "menuTables", + }; + } + } + + // Priority 2: fall back to settings if no valid URL param + if (!selectedMenu) { + const menuSettings = settings.menu; + if (menuSettings.overview) selectedMenu = "menuOverview"; + else if (menuSettings.dashboard) selectedMenu = "menuDashboard"; + else if (menuSettings.compare) selectedMenu = "menuCompare"; + else if (menuSettings.tables) selectedMenu = "menuTables"; + } + update_menu(selectedMenu); +} + +// ---- Data loading and spinner ---- + +// function to update all graph data, function is called when updating filters and when the page loads +function setup_data_and_graphs(menuUpdate = false, prepareOverviewProjectData = false) { + setup_spinner(false); // show spinner immediately + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (prepareOverviewProjectData) { + prepare_overview(); + setup_overview_localstorage(); + setup_overview_section_layout_buttons(); + setup_overview_order_filters(); + update_overview_version_select_list(); + } + setup_filtered_data_and_filters(); + setup_section_order(); + setup_graph_order(); + setup_information_popups(); + setup_graph_view_buttons(); + setup_theme(); + + // let the page sections and events be setup before removing the spinner + // then load the graphs + requestAnimationFrame(() => { + setup_spinner(true); + setup_dashboard_section_menu_buttons(); + setup_overview_section_menu_buttons(); + + // Always create graphs from scratch because setup_graph_order() + // rebuilds all GridStack grids and canvas DOM elements above + create_dashboard_graphs(); + + // Ensure overview titles reflect current prefix setting + update_overview_prefix_display(); + + document.dispatchEvent(new Event("graphs-finalized")); + + if (!menuUpdate) { + setTimeout(() => { + const mostVisibleSectionId = get_most_visible_section(); + if (mostVisibleSectionId) { + const offsetTop = document.getElementById(mostVisibleSectionId).getBoundingClientRect().top; + window.scrollTo({ + top: offsetTop - 67, + behavior: "auto" + }); + } + }, 100); + } + }); + }); + }); +} + + +// function to add a spinner for slow loads +function setup_spinner(hide) { + if (hide) { + // Instant transition - hide spinner and show all content immediately + $("#loading").fadeOut(200); + $("#overview").fadeIn(200); + $("#unified").fadeIn(200); + $("#dashboard").fadeIn(200); + $("#compare").fadeIn(200); + $("#tables").fadeIn(200); + } else { + $("#overview").hide() + $("#unified").hide() + $("#dashboard").hide() + $("#compare").hide() + $("#tables").hide() + $("#loading").show(); + } +} + +// ---- Section menu buttons ---- + // 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 @@ -128,6 +270,7 @@ function setup_dashboard_section_menu_buttons() { }); } + // 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. @@ -189,28 +332,7 @@ function setup_overview_section_menu_buttons() { btn.addEventListener("click", (e) => { e.preventDefault(); - const stickyTop = document.getElementById("navigation"); - const stickyHeight = stickyTop ? stickyTop.offsetHeight : 0; - - 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(); + expand_and_scroll_to(sectionEl); }); } buttonMap.set(sectionId, btn); @@ -254,28 +376,19 @@ function setup_overview_section_menu_buttons() { name = name.replace(/^project_/, ''); } const label = btn.querySelector('i'); + if (label) label.textContent = name; }); // 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 + // Reorder the visible buttons (up to 3) in the navbar after the Overview link let last = overviewMenuLink; - desiredOrder.forEach(idx => { - const section = sections[idx]; - const btn = buttonMap.get(section.id); + indices.forEach(idx => { + const btn = buttonMap.get(sections[idx].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); - } + btn.hidden = !showButtons; + if (last.nextSibling !== btn) insertAfter(btn, last); last = btn; }); @@ -283,8 +396,7 @@ function setup_overview_section_menu_buttons() { sections.forEach((section, idx) => { const btn = buttonMap.get(section.id); if (!btn) return; - const shouldShow = showButtons && desiredOrder.includes(idx); - btn.hidden = !shouldShow; + btn.hidden = !showButtons || !indices.includes(idx); btn.classList.toggle("active", showButtons && idx === bestIndex); }); }; @@ -296,6 +408,7 @@ function setup_overview_section_menu_buttons() { updateVisibleButtons(); } + function get_most_visible_section() { const SECTION_IDS = [ "overviewStatisticsSection", @@ -336,285 +449,219 @@ function get_most_visible_section() { return bestMatchId; } -function update_menu(item) { - ["overview", "dashboard", "compare", "tables"].forEach(menuItem => { - set_local_storage_item(`menu.${menuItem}`, (item === `menu${menuItem.charAt(0).toUpperCase() + menuItem.slice(1)}`)); - }); - ["menuOverview", "menuDashboard", "menuCompare", "menuTables"].forEach(id => { - document.getElementById(id).classList.toggle("active", id === item); - }); - document.getElementById("filters").hidden = (item === "menuOverview"); - setup_data_and_graphs(true, item === "menuOverview" && !areGroupedProjectsPrepared); -} - -// function to setup the menu eventlisteners -function setup_menu() { - document.getElementById("menuOverview").addEventListener("click", () => update_menu("menuOverview")); - document.getElementById("menuDashboard").addEventListener("click", () => update_menu("menuDashboard")); - document.getElementById("menuCompare").addEventListener("click", () => update_menu("menuCompare")); - document.getElementById("menuTables").addEventListener("click", () => update_menu("menuTables")); - - const params = new URLSearchParams(window.location.search); - const pageParam = params.get("page"); - let selectedMenu; - - if (pageParam) { - switch (pageParam.toLowerCase()) { - case "overview": - selectedMenu = "menuOverview"; - break; - case "dashboard": - selectedMenu = "menuDashboard"; - break; - case "compare": - selectedMenu = "menuCompare"; - break; - case "tables": - selectedMenu = "menuTables"; - break; - } - if (selectedMenu) { - settings.menu = { - overview: selectedMenu === "menuOverview", - dashboard: selectedMenu === "menuDashboard", - compare: selectedMenu === "menuCompare", - tables: selectedMenu === "menuTables", - }; - } - } - - // Priority 2: fall back to settings if no valid URL param - if (!selectedMenu) { - const menuSettings = settings.menu; - if (menuSettings.overview) selectedMenu = "menuOverview"; - else if (menuSettings.dashboard) selectedMenu = "menuDashboard"; - else if (menuSettings.compare) selectedMenu = "menuCompare"; - else if (menuSettings.tables) selectedMenu = "menuTables"; - } - update_menu(selectedMenu); -} - -// function to update all graph data, function is called when updating filters and when the page loads -function setup_data_and_graphs(menuUpdate = false, prepareOverviewProjectData = false) { - setup_spinner(false); // show spinner immediately - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (prepareOverviewProjectData) { - prepare_overview(); - setup_overview_localstorage(); - setup_overview_section_layout_buttons(); - setup_overview_order_filters(); - update_overview_version_select_list(); - } - setup_filtered_data_and_filters(); - setup_section_order(); - setup_graph_order(); - setup_information_popups(); - setup_graph_view_buttons(); - setup_theme(); - - // let the page sections and events be setup before removing the spinner - // then load the graphs - requestAnimationFrame(() => { - setup_spinner(true); - setup_dashboard_section_menu_buttons(); - setup_overview_section_menu_buttons(); - - // Always create graphs from scratch because setup_graph_order() - // rebuilds all GridStack grids and canvas DOM elements above - create_dashboard_graphs(); - - // Ensure overview titles reflect current prefix setting - update_overview_prefix_display(); - - document.dispatchEvent(new Event("graphs-finalized")); - - if (!menuUpdate) { - setTimeout(() => { - const mostVisibleSectionId = get_most_visible_section(); - if (mostVisibleSectionId) { - const offsetTop = document.getElementById(mostVisibleSectionId).getBoundingClientRect().top; - window.scrollTo({ - top: offsetTop - 67, - behavior: "auto" - }); - } - }, 100); - } - }); - }); - }); -} - -// function to add a spinner for slow loads -function setup_spinner(hide) { - if (hide) { - // Instant transition - hide spinner and show all content immediately - $("#loading").fadeOut(200); - $("#overview").fadeIn(200); - $("#unified").fadeIn(200); - $("#dashboard").fadeIn(200); - $("#compare").fadeIn(200); - $("#tables").fadeIn(200); - } else { - $("#overview").hide() - $("#unified").hide() - $("#dashboard").hide() - $("#compare").hide() - $("#tables").hide() - $("#loading").show(); - } -} - -export { - setup_menu, - setup_data_and_graphs, - setup_spinner, - update_menu, - setup_overview_section_menu_buttons, - setup_navbar_overflow -}; - -// ── Responsive navbar overflow ───────────────────────────────────────────── +// ---- Responsive navbar / sidebar ---- // -// Level 0: everything visible in the navbar -// Level 1: page-menu items (Overview, Dashboard, …) collapse into a hamburger -// Level 2: icons collapse into a Windows-tray-style popup (↑ button) -// Level 3: custom title is abbreviated with ellipsis +// Level 1: page-menu items (Overview, Dashboard, …) move into the sidebar +// Level 2: icon items also move into the sidebar // function setup_navbar_overflow() { const nav = document.getElementById('navigation'); const mainNavDiv = document.getElementById('mainNavItems'); - const hamburgerWrap = document.getElementById('navHamburgerWrap'); - const hamburgerMenu = document.getElementById('navHamburgerMenu'); + const hamburgerBtn = document.getElementById('navHamburger'); const iconNavUl = document.getElementById('iconNavItems'); - const iconTrayWrap = document.getElementById('iconTrayWrap'); - const iconTrayToggle = document.getElementById('iconTrayToggle'); - const iconTrayPopup = document.getElementById('iconTrayPopup'); - const titleEl = document.getElementById('menuCustomTitle'); - - // IDs of the page-nav items that go into the hamburger at level 1 - const NAV_ITEM_IDS = [ - 'menuOverview', 'menuDashboard', - 'runStatisticsSectionNav', 'suiteStatisticsSectionNav', - 'testStatisticsSectionNav', 'keywordStatisticsSectionNav', - 'menuCompare', 'menuTables', 'openDashboard', - ]; + const sidenav = document.getElementById('sidenav'); + const sidenavBody = document.getElementById('sidenavBody'); + const sidenavBackdrop = document.getElementById('sidenavBackdrop'); + const sidenavClose = document.getElementById('sidenavClose'); - // Capture icon
  • references once (they move, but references stay valid) - const iconLiEls = Array.from(iconNavUl.children).filter(li => li.id !== 'iconTrayWrap'); + // Capture icon
  • references (they move, but references stay valid) + const iconLiEls = Array.from(iconNavUl.children); - // State flags (idempotent guards) - let hamburgerActive = false; - let trayActive = false; - let titleAbbrev = false; - - // ── helpers ────────────────────────────────────────────────────────────── + let navInSidebar = false; + let iconsInSidebar = false; + let updating = false; function nav_item_els() { - // Always resolved by ID so they're found wherever they currently live - return NAV_ITEM_IDS.map(id => document.getElementById(id)).filter(Boolean); + return Array.from(mainNavDiv.querySelectorAll('.nav-item')) + .filter(el => el.id !== 'menuCustomTitle'); } function is_overflowing() { - // Bootstrap's .navbar has flex-wrap:wrap (nav grows taller instead of - // overflowing) and flex-shrink:1 on children (items shrink to near zero). - // Force a true single-row layout on all three containers for measurement. - const nfw = nav.style.flexWrap; - const mfs = mainNavDiv.style.flexShrink; - const ufw = iconNavUl.style.flexWrap; - const ufs = iconNavUl.style.flexShrink; - nav.style.flexWrap = 'nowrap'; + // Temporarily disable flex-shrink so we can measure natural width + const saved = mainNavDiv.style.flexShrink; mainNavDiv.style.flexShrink = '0'; - iconNavUl.style.flexWrap = 'nowrap'; - iconNavUl.style.flexShrink = '0'; const overflows = nav.scrollWidth > nav.clientWidth + 1; - nav.style.flexWrap = nfw; - mainNavDiv.style.flexShrink = mfs; - iconNavUl.style.flexWrap = ufw; - iconNavUl.style.flexShrink = ufs; + mainNavDiv.style.flexShrink = saved; return overflows; } - // ── level transitions ──────────────────────────────────────────────────── + function open_sidebar() { + sidenav.hidden = false; + sidenavBackdrop.hidden = false; + void sidenav.offsetHeight; // reflow for CSS transition + sidenav.classList.add('sidenav-open'); + } - function apply_hamburger(on) { - if (on === hamburgerActive) return; - hamburgerActive = on; - if (on) { - hamburgerWrap.hidden = false; - nav_item_els().forEach(el => hamburgerMenu.appendChild(el)); - } else { - nav_item_els().forEach(el => mainNavDiv.appendChild(el)); - hamburgerWrap.hidden = true; + function close_sidebar() { + sidenav.classList.remove('sidenav-open'); + sidenavBackdrop.hidden = true; + } + + hamburgerBtn.addEventListener('click', open_sidebar); + sidenavClose.addEventListener('click', close_sidebar); + sidenavBackdrop.addEventListener('click', close_sidebar); + + // Build an ordered list of nav items for the sidebar, matching the + // actual section order on the page (which can be rearranged by the user). + function ordered_sidebar_items() { + const items = []; + const byId = id => document.getElementById(id); + + // Helper: create an entry for a nav element (if it exists and should show) + const push = (el, forceShow) => { + if (!el) return; + const isOverviewSub = el.id && el.id.startsWith('overview-') && el.id.endsWith('Nav'); + if (el.hidden && !isOverviewSub && !forceShow) return; + items.push(el); + }; + + // Overview + push(byId('menuOverview')); + // Overview sub-items in actual DOM section order + if (settings.menu && settings.menu.overview) { + const overviewBars = Array.from(document.querySelectorAll('#overview .overview-bar')) + .filter(el => el.offsetParent !== null || !el.hidden); + overviewBars.forEach(bar => { + const btn = byId(`overview-${bar.id}Nav`); + if (btn) items.push(btn); + }); } + + // Dashboard + push(byId('menuDashboard')); + // Dashboard sub-items in settings order + if (settings.menu && settings.menu.dashboard && !settings.show.unified) { + const dashSections = settings.view.dashboard.sections; + dashSections.show.forEach(name => { + const navId = space_to_camelcase(name) + 'SectionNav'; + push(byId(navId)); + }); + } + + // Remaining pages + push(byId('menuCompare')); + push(byId('menuTables')); + push(byId('openDashboard')); + + return items; } - function apply_icon_tray(on) { - if (on === trayActive) return; - trayActive = on; - if (on) { - iconTrayWrap.hidden = false; - iconLiEls.forEach(li => iconTrayPopup.appendChild(li)); - } else { - iconTrayPopup.hidden = true; - iconLiEls.forEach(li => iconNavUl.insertBefore(li, iconTrayWrap)); - iconTrayWrap.hidden = true; + function build_sidebar_content() { + sidenavBody.innerHTML = ''; + + if (navInSidebar) { + const label = document.createElement('div'); + label.className = 'sidenav-section-label'; + label.textContent = 'Pages'; + sidenavBody.appendChild(label); + + ordered_sidebar_items().forEach(el => { + const isSubmenu = !!el.querySelector('i'); + const item = document.createElement('a'); + item.className = 'sidenav-nav-item' + (isSubmenu ? ' sidenav-nav-submenu' : ''); + if (el.classList.contains('active')) item.classList.add('active'); + item.textContent = el.textContent; + item.addEventListener('click', () => { + el.click(); + close_sidebar(); + sidenavBody.querySelectorAll('.sidenav-nav-item').forEach(n => n.classList.remove('active')); + item.classList.add('active'); + }); + sidenavBody.appendChild(item); + }); + } + + if (iconsInSidebar) { + const label = document.createElement('div'); + label.className = 'sidenav-section-label'; + label.textContent = 'Shortcuts'; + sidenavBody.appendChild(label); + + const row = document.createElement('div'); + row.className = 'sidenav-icon-row'; + iconLiEls.forEach(li => { + if (li.hidden) return; + const link = li.querySelector('a'); + if (!link) return; + const clone = link.cloneNode(true); + clone.removeAttribute('id'); // prevent duplicate IDs conflicting with theme SVG updates + clone.addEventListener('click', (e) => { + if (link.dataset.bsToggle) { + e.preventDefault(); + close_sidebar(); + link.click(); + } else if (!link.getAttribute('target')) { + e.preventDefault(); + close_sidebar(); + link.click(); + } else { + close_sidebar(); + } + }); + row.appendChild(clone); + }); + sidenavBody.appendChild(row); } } - function apply_title_abbrev(on) { - if (on === titleAbbrev || !titleEl) return; - titleAbbrev = on; - titleEl.classList.toggle('title-abbreviated', on); + function apply_nav_to_sidebar(on) { + if (on === navInSidebar) return; + navInSidebar = on; + nav_item_els().forEach(el => { + el.style.display = on ? 'none' : ''; + }); } - // ── core update ────────────────────────────────────────────────────────── + function apply_icons_to_sidebar(on) { + if (on === iconsInSidebar) return; + iconsInSidebar = on; + iconLiEls.forEach(li => { + li.style.display = on ? 'none' : ''; + }); + } function update_overflow() { - // Reset to level 0 for fresh measurement (idempotent helpers mean this - // is cheap when we're already at level 0) - apply_title_abbrev(false); - apply_icon_tray(false); - apply_hamburger(false); - - if (!is_overflowing()) return; // level 0 fits - apply_hamburger(true); - if (!is_overflowing()) return; // level 1 fits - apply_icon_tray(true); - if (!is_overflowing()) return; // level 2 fits - apply_title_abbrev(true); // level 3 - } + if (updating) return; + updating = true; - // ── icon-tray popup positioning & toggle ───────────────────────────────── - - iconTrayToggle.addEventListener('click', (e) => { - e.stopPropagation(); - const nowHidden = !iconTrayPopup.hidden; - iconTrayPopup.hidden = nowHidden; - if (!nowHidden) { - const r = iconTrayToggle.getBoundingClientRect(); - iconTrayPopup.style.top = (r.bottom + 4) + 'px'; - iconTrayPopup.style.right = (window.innerWidth - r.right) + 'px'; - iconTrayPopup.style.left = ''; - } - }); + // Reset to level 0 for fresh measurement + apply_icons_to_sidebar(false); + apply_nav_to_sidebar(false); - document.addEventListener('click', (e) => { - if (!iconTrayPopup.hidden && !iconTrayPopup.contains(e.target)) { - iconTrayPopup.hidden = true; + const shouldShowHamburger = is_overflowing(); + + if (shouldShowHamburger) { + apply_nav_to_sidebar(true); // level 1 + if (is_overflowing()) { + apply_icons_to_sidebar(true); // level 2 + } } - }); - // ── observe nav width ──────────────────────────────────────────────────── + hamburgerBtn.hidden = !navInSidebar && !iconsInSidebar; + build_sidebar_content(); + updating = false; + } let debounce = null; - const ro = new ResizeObserver(() => { + const triggerUpdate = () => { clearTimeout(debounce); debounce = setTimeout(update_overflow, 40); - }); + }; + const ro = new ResizeObserver(triggerUpdate); ro.observe(nav); - // Initial run (after first paint so sizes are accurate) + // Re-check when dynamic nav items are added/removed (e.g. overview sub-items) + const mo = new MutationObserver(triggerUpdate); + mo.observe(mainNavDiv, { childList: true, subtree: true, attributes: true, attributeFilter: ['hidden'] }); + requestAnimationFrame(update_overflow); -} \ No newline at end of file +} + +export { + setup_menu, + setup_data_and_graphs, + setup_spinner, + update_menu, + setup_overview_section_menu_buttons, + setup_navbar_overflow +}; diff --git a/robotframework_dashboard/js/theme.js b/robotframework_dashboard/js/theme.js index 7d0972c..a9602c3 100644 --- a/robotframework_dashboard/js/theme.js +++ b/robotframework_dashboard/js/theme.js @@ -95,7 +95,6 @@ function setup_theme() { "customizeLayout": customizeViewSVG(color), "saveLayout": saveSVG(color), "navHamburger": menuSVG(color), - "iconTrayToggle": moveUpSVG(color), }, classes: { ".percentage-graph": percentageSVG(color), diff --git a/robotframework_dashboard/templates/dashboard.html b/robotframework_dashboard/templates/dashboard.html index 0c7aa32..8808231 100644 --- a/robotframework_dashboard/templates/dashboard.html +++ b/robotframework_dashboard/templates/dashboard.html @@ -18,16 +18,20 @@
    -