diff --git a/.claude/skills/dev-workflow/SKILL.md b/.claude/skills/dev-workflow/SKILL.md index 5b717ce4..a535f084 100644 --- a/.claude/skills/dev-workflow/SKILL.md +++ b/.claude/skills/dev-workflow/SKILL.md @@ -13,7 +13,7 @@ The `robotdashboard` command only exists after `pip install`, and an installed c python -m robotframework_dashboard.main -f tests -n robot_dashboard.html ``` -This invokes `robotframework_dashboard/main.py:main()` and accepts every CLI flag. `tests/` contains 18 ready-made `output.xml` fixtures (two simulated projects, generated by `tests/robot/resources/generator/generate.py`), so the command above is the standard "give me a dashboard to look at". For the full-featured example with tags, versions, custom filters and log links use `python scripts/example.py` instead. +This invokes `robotframework_dashboard/main.py:main()` and accepts every CLI flag. `tests/` contains 18 ready-made `output.xml` fixtures (two simulated projects, generated by `tests/robot/resources/generator/generate.py`), so the command above is the standard "give me a dashboard to look at". For the full-featured example with tags, versions, custom filters and log links use `python scripts/example.py --test` instead (without `--test` it also overwrites the committed `example/` files, which is a release step). Common variants: diff --git a/CLAUDE.md b/CLAUDE.md index ac683715..917025ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ Use the project scripts — do **not** call `pytest`, `vitest`, `robot`, or `pab | Python unit tests | `bash scripts/python-tests.sh` / `scripts\python-tests.bat` | | JS unit tests | `bash scripts/javascript-tests.sh` / `scripts\javascript-tests.bat` | | Generate a dashboard from source | `python -m robotframework_dashboard.main -f tests -n robot_dashboard.html` | -| Build the example dashboard (tags, versions, filters, logs) | `python scripts/example.py` / `scripts\example.bat` | +| Build the example dashboard (tags, versions, filters, logs) | `python scripts/example.py` / `scripts\example.bat`; add `--test` to build only in the repo root and leave `example/` untouched | | Regenerate the `output.xml` fixtures | `python tests/robot/resources/generator/generate.py` (then refresh screenshots/CLI/DB references in Docker) | | Docs site | `npm run docs:dev` / `npm run docs:build` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d6122d8..6882416b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,7 +68,7 @@ python tests/robot/resources/generator/generate.py then refresh the reference screenshots, `cli_output` and `database_output` files by running the robot tests in Docker. The generator suites are not part of the test pipeline. See `tests/robot/resources/generator/README.md`. -The example dashboard in `example/` is built from the same fixtures with `python scripts/example.py` (or `scripts\example.bat` on Windows). +The example dashboard in `example/` is built from the same fixtures with `python scripts/example.py` (or `scripts\example.bat` on Windows). Add `--test` to build `robot_dashboard.html` in the repo root without updating `example/`, handy for eyeballing a change. ## Running Tests Locally - in a Docker Container Run the tests locally on your PC before pushing and waiting for the results from the GitHub actions is always a good idea. But this requires to install the required components in your native PC. In some cases this will not work as expected bacause of the differemt versions used. E.g. screenshots taken during the tests may differ, so that the tests might fail. diff --git a/robotframework_dashboard/css/components/02-filters.css b/robotframework_dashboard/css/components/02-filters.css index 4754b125..d07a4e31 100644 --- a/robotframework_dashboard/css/components/02-filters.css +++ b/robotframework_dashboard/css/components/02-filters.css @@ -43,6 +43,19 @@ padding: 0.25rem; } +/* the wrapper of select + panel is the anchor, so a panel flipped by set_filter_dropdown_visible + sits directly on top of its select and shrinks towards it while the list is being searched */ +:has(> .filterCheckBoxes) { + position: relative; +} + +.filterCheckBoxes.drop-up { + top: auto; + bottom: 100%; + margin-top: 0 !important; + margin-bottom: 0.5rem; +} + .filterCheckBoxes .list-group-item { background-color: transparent; border-color: transparent; diff --git a/robotframework_dashboard/js/eventlisteners/filter_modal.js b/robotframework_dashboard/js/eventlisteners/filter_modal.js index 084ea9a7..42f52fb2 100644 --- a/robotframework_dashboard/js/eventlisteners/filter_modal.js +++ b/robotframework_dashboard/js/eventlisteners/filter_modal.js @@ -14,7 +14,7 @@ import { setup_custom_filters_in_select_filter_buttons, } from "../filter/modal_options.js"; import { setup_suite_path_navigator } from "../filter/suite_path.js"; -import { clear_all_filters } from "../filter/controls.js"; +import { clear_all_filters, set_filter_dropdown_visible } from "../filter/controls.js"; import { capture_default_filters, populate_filter_profile_select, @@ -89,9 +89,8 @@ function setup_filter_modal() { }); // eventlistener for the runTags function show_checkboxes() { - const checkboxes = document.getElementById("runTagCheckBoxes"); showingRunTags = !showingRunTags; - checkboxes.style.display = showingRunTags ? "block" : "none"; + set_filter_dropdown_visible(runTagsSelectElement, checkboxesElement, showingRunTags); } const checkboxesElement = document.getElementById("runTagCheckBoxes"); const runTagsSelectElement = document.getElementById("selectRunTags"); @@ -107,7 +106,7 @@ function setup_filter_modal() { const projectVersionSelectElement = document.getElementById("selectProjectVersion"); function toggle_project_version_filter_dialogue() { showingProjectVersionDialogue = !showingProjectVersionDialogue; - projectVersionCheckboxes.style.display = showingProjectVersionDialogue ? "block" : "none"; + set_filter_dropdown_visible(projectVersionSelectElement, projectVersionCheckboxes, showingProjectVersionDialogue); } projectVersionSelectElement.addEventListener("pointerdown", toggle_project_version_filter_dialogue); document.body.addEventListener("pointerdown", function (event) { @@ -166,7 +165,7 @@ function setup_filter_modal() { let showingFilterProfiles = false; function toggle_filter_profiles() { showingFilterProfiles = !showingFilterProfiles; - document.getElementById("filterProfileCheckBoxes").style.display = showingFilterProfiles ? "block" : "none"; + set_filter_dropdown_visible(selectFilterProfileElement, filterProfileCheckBoxes, showingFilterProfiles); } document.getElementById("selectFilterProfile").addEventListener("click", toggle_filter_profiles); const filterProfileCheckBoxes = document.getElementById("filterProfileCheckBoxes"); diff --git a/robotframework_dashboard/js/eventlisteners/settings_modal.js b/robotframework_dashboard/js/eventlisteners/settings_modal.js index f4a09298..7f0ad850 100644 --- a/robotframework_dashboard/js/eventlisteners/settings_modal.js +++ b/robotframework_dashboard/js/eventlisteners/settings_modal.js @@ -7,6 +7,7 @@ import { setup_data_and_graphs } from "../menu.js"; import { toggle_theme, apply_theme_colors, apply_custom_branding } from "../theme.js"; import { collect_custom_filter_dimensions, dashboardPages } from "../filter/pipeline.js"; import { setup_lowest_highest_dates } from "../filter/modal_options.js"; +import { set_filter_dropdown_visible } from "../filter/controls.js"; import { update_duration_comparison_for_all_projects } from "../graph_creation/overview.js"; import { confirm_action } from "./confirm_modal.js"; @@ -89,7 +90,7 @@ function setup_settings_modal() { let showing = false; function toggle() { showing = !showing; - checkBoxesEl.style.display = showing ? "block" : "none"; + set_filter_dropdown_visible(selectEl, checkBoxesEl, showing); } selectEl.addEventListener("pointerdown", toggle); document.body.addEventListener("pointerdown", function (event) { diff --git a/robotframework_dashboard/js/filter/controls.js b/robotframework_dashboard/js/filter/controls.js index 2f8588ad..3ce9d5b0 100644 --- a/robotframework_dashboard/js/filter/controls.js +++ b/robotframework_dashboard/js/filter/controls.js @@ -181,9 +181,45 @@ function set_filter_show_current_version(version) { update_filter_active_indicator("projectVersionInputItemAll", "filterVersionSelectedIndicator"); } +// gap between the select and its dropdown panel (the mt-2 margin) plus breathing room to the viewport edge +const FILTER_DROPDOWN_GAP = 8; +const FILTER_DROPDOWN_EDGE_MARGIN = 16; +const FILTER_DROPDOWN_MIN_HEIGHT = 120; + +// decide whether a dropdown panel opens below or above its select and how tall it may get: +// below while the content fits there, otherwise on whichever side has more room +function get_filter_dropdown_placement(spaceBelow, spaceAbove, contentHeight, defaultMaxHeight) { + const wantedHeight = Math.min(contentHeight, defaultMaxHeight); + const dropUp = wantedHeight > spaceBelow && spaceAbove > spaceBelow; + const available = dropUp ? spaceAbove : spaceBelow; + const maxHeight = Math.max(Math.min(defaultMaxHeight, available), FILTER_DROPDOWN_MIN_HEIGHT); + return { dropUp, maxHeight }; +} + +// show or hide a .filterCheckBoxes panel; when shown it flips above the select if there is not +// enough room below, so opening it does not stretch the modal and make the page jump +function set_filter_dropdown_visible(selectElement, panelElement, visible) { + if (!visible) { + panelElement.style.display = "none"; + return; + } + panelElement.classList.remove("drop-up"); + panelElement.style.display = "block"; + const selectRect = selectElement.getBoundingClientRect(); + const spaceBelow = window.innerHeight - selectRect.bottom - FILTER_DROPDOWN_GAP - FILTER_DROPDOWN_EDGE_MARGIN; + const spaceAbove = selectRect.top - FILTER_DROPDOWN_GAP - FILTER_DROPDOWN_EDGE_MARGIN; + const { dropUp, maxHeight } = get_filter_dropdown_placement( + spaceBelow, spaceAbove, panelElement.scrollHeight, window.innerHeight * 0.5 + ); + panelElement.classList.toggle("drop-up", dropUp); + panelElement.style.maxHeight = `${maxHeight}px`; +} + export { clear_all_filters, clear_overview_project_navigation_filter, + get_filter_dropdown_placement, + set_filter_dropdown_visible, set_filter_show_current_version, setup_filter_active_indicator, setup_filter_checkbox_handler_listeners, diff --git a/robotframework_dashboard/js/filter/modal_options.js b/robotframework_dashboard/js/filter/modal_options.js index ab639a34..e1ac66b2 100644 --- a/robotframework_dashboard/js/filter/modal_options.js +++ b/robotframework_dashboard/js/filter/modal_options.js @@ -1,7 +1,7 @@ import { settings } from '../variables/settings.js'; import { runs } from '../variables/data.js'; import { collect_custom_filter_dimensions } from './pipeline.js'; -import { setup_filter_active_indicator, setup_filter_checkbox_handler_listeners, setup_filter_checkbox_subfilter } from './controls.js'; +import { set_filter_dropdown_visible, setup_filter_active_indicator, setup_filter_checkbox_handler_listeners, setup_filter_checkbox_subfilter } from './controls.js'; // function to setup run amount filter maximum function setup_run_amount_filter() { @@ -262,7 +262,7 @@ function setup_custom_filters_in_select_filter_buttons() { const selectEl = document.getElementById(selectId); const checkBoxesEl = document.getElementById(checkBoxesId); let showing = false; - function toggle() { showing = !showing; checkBoxesEl.style.display = showing ? "block" : "none"; } + function toggle() { showing = !showing; set_filter_dropdown_visible(selectEl, checkBoxesEl, showing); } selectEl.addEventListener("pointerdown", toggle); document.body.addEventListener("pointerdown", function (event) { if (showing && !checkBoxesEl.contains(event.target) && !selectEl.contains(event.target)) { diff --git a/scripts/example.py b/scripts/example.py index 6b1a2fdc..7a9634c8 100644 --- a/scripts/example.py +++ b/scripts/example.py @@ -1,13 +1,15 @@ """Build the example dashboard (example/robot_dashboard.html + example/robot_results.db). python scripts/example.py + python scripts/example.py --test # leave example/ untouched, only build in the repo root Imports every fixture from tests/robot/resources/outputs/ with run tags, project versions, timezones and custom filters so the example shows all dashboard features, -then copies the result into example/. Runs the package from source (python -m), so +then copies the result into example/ (skipped with --test). Runs the package from source (python -m), so no install is needed. The fixtures come from tests/robot/resources/generator/. """ +import argparse import shutil import subprocess import sys @@ -70,12 +72,22 @@ def build_commands(): def main(): + parser = argparse.ArgumentParser(description="Build the example dashboard and database.") + parser.add_argument( + "--test", + action="store_true", + help=f"only build {DASHBOARD.name} and {DATABASE.name} in the repo root, do not update example/", + ) + args = parser.parse_args() for stale in (DATABASE, DASHBOARD): if stale.exists(): stale.unlink() for command in build_commands(): print(" ".join(command[3:5]), flush=True) subprocess.run(command, cwd=ROOT, check=True) + if args.test: + print(f"Built {DASHBOARD} and {DATABASE}, example/ left untouched") + return shutil.copy(DATABASE, EXAMPLE / DATABASE.name) shutil.copy(DASHBOARD, EXAMPLE / DASHBOARD.name) print(f"Updated {EXAMPLE / DATABASE.name} and {EXAMPLE / DASHBOARD.name}") diff --git a/tests/javascript/filter_dropdown.test.js b/tests/javascript/filter_dropdown.test.js new file mode 100644 index 00000000..ad5f5fff --- /dev/null +++ b/tests/javascript/filter_dropdown.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@js/variables/globals.js', () => import('./mocks/globals.js')); +vi.mock('@js/variables/data.js', () => import('./mocks/data.js')); +vi.mock('@js/variables/graphs.js', () => import('./mocks/graphs.js')); + +import { get_filter_dropdown_placement } from '@js/filter/controls.js'; + +describe('get_filter_dropdown_placement', () => { + it('opens below when the content fits below', () => { + expect(get_filter_dropdown_placement(400, 600, 300, 500)).toEqual({ dropUp: false, maxHeight: 400 }); + }); + + it('opens above when the content does not fit below and there is more room above', () => { + expect(get_filter_dropdown_placement(150, 600, 300, 500)).toEqual({ dropUp: true, maxHeight: 500 }); + }); + + it('stays below when the content does not fit either way but below has more room', () => { + expect(get_filter_dropdown_placement(250, 200, 300, 500)).toEqual({ dropUp: false, maxHeight: 250 }); + }); + + it('caps the height at the room above when flipped', () => { + expect(get_filter_dropdown_placement(100, 220, 400, 500)).toEqual({ dropUp: true, maxHeight: 220 }); + }); + + it('only compares against the default max height for very long lists', () => { + // 2000px of options, but the panel never grows beyond 500px, which fits below + expect(get_filter_dropdown_placement(550, 800, 2000, 500)).toEqual({ dropUp: false, maxHeight: 500 }); + }); + + it('never shrinks below the minimum height', () => { + expect(get_filter_dropdown_placement(20, 10, 300, 500)).toEqual({ dropUp: false, maxHeight: 120 }); + }); +});