1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-04 16:02:51 +01:00

feat: add feedback dialog

This commit is contained in:
2026-02-21 14:47:02 +01:00
parent 402c721279
commit d99d5e1370
4 changed files with 586 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import os
from typing import TYPE_CHECKING
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
from qtpy.QtGui import QAction, QActionGroup, QIcon
@@ -31,6 +32,7 @@ from bec_widgets.widgets.containers.main_window.addons.notification_center.notif
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
from bec_widgets.widgets.utility.feedback_dialog.feedback_dialog import FeedbackDialog
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
@@ -342,6 +344,34 @@ class BECMainWindow(BECWidget, QMainWindow):
help_menu.addAction(widgets_docs)
help_menu.addAction(bug_report)
# Add separator before feedback
help_menu.addSeparator()
# Feedback action
feedback_icon = QApplication.style().standardIcon(
QStyle.StandardPixmap.SP_MessageBoxQuestion
)
feedback_action = QAction("Feedback", self)
feedback_action.setIcon(feedback_icon)
feedback_action.triggered.connect(self._show_feedback_dialog)
help_menu.addAction(feedback_action)
def _show_feedback_dialog(self):
"""Show the feedback dialog and handle the submitted feedback."""
dialog = FeedbackDialog(self)
def on_feedback_submitted(rating: int, comment: str, email: str):
rating = max(1, min(rating, 5)) # Ensure rating is between 1 and 5
username = os.getlogin()
message = messages.FeedbackMessage(
feedback=comment, rating=rating, contact=email, username=username
)
self.bec_dispatcher.client.connector.send(MessageEndpoints.submit_feedback(), message)
dialog.feedback_submitted.connect(on_feedback_submitted)
dialog.exec()
################################################################################
# Status Bar Addons
################################################################################

View File

@@ -0,0 +1,294 @@
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QColor, QFont
from qtpy.QtWidgets import (
QApplication,
QDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QTextEdit,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
class StarRating(QWidget):
"""
A star rating widget that allows users to rate from 1 to 5 stars.
"""
rating_changed = Signal(int)
def __init__(self, parent=None):
super().__init__(parent)
self._rating = 0
self._hovered_star = 0
self._star_buttons = []
# Get theme colors
theme = getattr(QApplication.instance(), "theme", None)
if theme:
SafeConnect(self, theme.theme_changed, self._update_theme_colors)
self._update_theme_colors()
# Enable mouse tracking to handle hover across the entire widget
self.setMouseTracking(True)
layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
for i in range(5):
btn = QPushButton("")
btn.setFixedSize(30, 30)
btn.setFlat(True)
btn.setCursor(Qt.CursorShape.PointingHandCursor)
btn.clicked.connect(lambda checked=False, idx=i + 1: self._set_rating(idx))
layout.addWidget(btn)
self._star_buttons.append(btn)
self.setLayout(layout)
self._update_display()
@SafeSlot(str)
def _update_theme_colors(self, _theme: str | None = None):
"""Update colors based on theme."""
theme = getattr(QApplication.instance(), "theme", None)
colors = theme.colors if theme else {}
self._inactive_color = colors.get("SEPARATOR", QColor(200, 200, 200))
self._active_color = colors.get("ACCENT_WARNING", QColor(255, 193, 7))
# Update display if already initialized
if hasattr(self, "_star_buttons") and self._star_buttons:
self._update_display()
def _set_rating(self, rating: int):
"""Set the rating and emit the signal."""
if self._rating != rating:
self._rating = rating
self.rating_changed.emit(rating)
self._update_display()
def mouseMoveEvent(self, event):
"""Handle mouse movement to update hovered star."""
# Calculate which star is being hovered based on mouse position
x_pos = event.pos().x()
star_idx = 0
# Find which star region we're in (including gaps between stars)
for i, btn in enumerate(self._star_buttons):
btn_geometry = btn.geometry()
# If we're to the right of this button's left edge, this is the current star
# (including the gap before the next button)
if x_pos >= btn_geometry.left():
star_idx = i + 1
else:
break
if star_idx != self._hovered_star:
self._hovered_star = star_idx
self._update_display()
super().mouseMoveEvent(event)
def leaveEvent(self, event):
"""Handle mouse leaving the widget."""
self._hovered_star = 0
self._update_display()
super().leaveEvent(event)
def _update_display(self):
"""Update the visual display of stars."""
display_rating = self._hovered_star if self._hovered_star > 0 else self._rating
inactive_color_name = self._inactive_color.name()
active_color_name = self._active_color.name()
for i, btn in enumerate(self._star_buttons):
if i < display_rating:
btn.setStyleSheet(
f"""
QPushButton {{
border: none;
background: transparent;
font-size: 24px;
color: {active_color_name};
}}
"""
)
else:
btn.setStyleSheet(
f"""
QPushButton {{
border: none;
background: transparent;
font-size: 24px;
color: {inactive_color_name};
}}
QPushButton:hover {{
color: {active_color_name};
}}
"""
)
def rating(self) -> int:
"""Get the current rating."""
return self._rating
def set_rating(self, rating: int):
"""Set the rating programmatically."""
if 0 <= rating <= 5:
self._set_rating(rating)
class FeedbackDialog(QDialog):
"""
A feedback dialog widget containing a comment field, star rating, and optional email field.
Signals:
feedbackSubmitted: Emitted when feedback is submitted (rating: int, comment: str, email: str)
"""
feedback_submitted = Signal(int, str, str)
ICON_NAME = "feedback"
PLUGIN = True
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Feedback")
self.setModal(True)
self.setMinimumWidth(400)
self.setMinimumHeight(300)
self._setup_ui()
def _setup_ui(self):
"""Set up the user interface."""
layout = QVBoxLayout()
layout.setSpacing(15)
# Title
title_label = QLabel("We'd love to hear your feedback!")
title_font = QFont()
title_font.setPointSize(12)
title_font.setBold(True)
title_label.setFont(title_font)
layout.addWidget(title_label)
# Star rating section
rating_layout = QVBoxLayout()
rating_label = QLabel("Rating:")
rating_layout.addWidget(rating_label)
self._star_rating = StarRating()
rating_layout.addWidget(self._star_rating)
layout.addLayout(rating_layout)
# Comment section
comment_label = QLabel("Comments:")
layout.addWidget(comment_label)
self._comment_field = QTextEdit()
self._comment_field.setPlaceholderText("Please share your thoughts...")
self._comment_field.setMaximumHeight(150)
layout.addWidget(self._comment_field)
# Email section (optional)
email_label = QLabel("Email (optional, for follow-up):")
layout.addWidget(email_label)
self._email_field = QLineEdit()
self._email_field.setPlaceholderText("your.email@example.com")
layout.addWidget(self._email_field)
# Buttons
button_layout = QHBoxLayout()
button_layout.addStretch()
self._cancel_button = QPushButton("Cancel")
self._cancel_button.clicked.connect(self.reject)
button_layout.addWidget(self._cancel_button)
self._submit_button = QPushButton("Submit")
self._submit_button.setDefault(True)
self._submit_button.clicked.connect(self._on_submit)
button_layout.addWidget(self._submit_button)
layout.addLayout(button_layout)
self.setLayout(layout)
def _on_submit(self):
"""Handle submit button click."""
rating = self._star_rating.rating()
comment = self._comment_field.toPlainText().strip()
email = self._email_field.text().strip()
# Emit the feedback signal
self.feedback_submitted.emit(rating, comment, email)
# Accept the dialog
self.accept()
def get_feedback(self) -> tuple[int, str, str]:
"""
Get the current feedback values.
Returns:
tuple: (rating, comment, email)
"""
return (
self._star_rating.rating(),
self._comment_field.toPlainText().strip(),
self._email_field.text().strip(),
)
def set_rating(self, rating: int):
"""Set the star rating."""
self._star_rating.set_rating(rating)
def set_comment(self, comment: str):
"""Set the comment text."""
self._comment_field.setPlainText(comment)
def set_email(self, email: str):
"""Set the email text."""
self._email_field.setText(email)
@staticmethod
def show_feedback_dialog(parent=None) -> tuple[int, str, str] | None:
"""
Show the feedback dialog and return the feedback if submitted.
Args:
parent: Parent widget
Returns:
tuple: (rating, comment, email) if submitted, None if cancelled
"""
dialog = FeedbackDialog(parent)
if dialog.exec() == QDialog.DialogCode.Accepted:
return dialog.get_feedback()
return None
if __name__ == "__main__": # pragma: no cover
import sys
from bec_widgets.utils.colors import apply_theme
app = QApplication(sys.argv)
apply_theme("dark")
dialog = FeedbackDialog()
def on_feedback(rating, comment, email):
print(f"Rating: {rating}")
print(f"Comment: {comment}")
print(f"Email: {email}")
dialog.feedback_submitted.connect(on_feedback)
dialog.exec()
sys.exit(app.exec())

View File

@@ -0,0 +1,262 @@
import pytest
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QDialog
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.utility.feedback_dialog.feedback_dialog import FeedbackDialog, StarRating
@pytest.fixture
def star_rating(qtbot):
"""Create a StarRating widget for testing."""
widget = StarRating()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
@pytest.fixture
def feedback_dialog(qtbot):
"""Create a FeedbackDialog for testing."""
dialog = FeedbackDialog()
qtbot.addWidget(dialog)
qtbot.waitExposed(dialog)
yield dialog
dialog.close()
class TestStarRating:
"""Tests for the StarRating widget."""
def test_initial_state(self, star_rating):
"""Test that StarRating initializes with rating 0."""
assert star_rating.rating() == 0
assert star_rating._hovered_star == 0
assert len(star_rating._star_buttons) == 5
def test_set_rating_via_method(self, star_rating):
"""Test setting rating programmatically."""
star_rating.set_rating(3)
assert star_rating.rating() == 3
star_rating.set_rating(5)
assert star_rating.rating() == 5
def test_set_rating_bounds(self, star_rating):
"""Test that rating is bounded between 0 and 5."""
star_rating.set_rating(0)
assert star_rating.rating() == 0
star_rating.set_rating(5)
assert star_rating.rating() == 5
# Out of bounds should not change rating
initial_rating = star_rating.rating()
star_rating.set_rating(6)
assert star_rating.rating() == initial_rating
star_rating.set_rating(-1)
assert star_rating.rating() == initial_rating
def test_rating_signal_emission(self, star_rating, qtbot):
"""Test that rating_changed signal is emitted when rating changes."""
with qtbot.waitSignal(star_rating.rating_changed, timeout=1000) as blocker:
star_rating.set_rating(4)
assert blocker.args == [4]
def test_rating_signal_not_emitted_on_same_value(self, star_rating, qtbot):
"""Test that signal is not emitted when setting the same rating."""
star_rating.set_rating(3)
# Should not emit signal when setting same value
with qtbot.assertNotEmitted(star_rating.rating_changed, wait=100):
star_rating.set_rating(3)
def test_click_star_button(self, star_rating, qtbot):
"""Test clicking on star buttons."""
# Click the third star (index 2)
with qtbot.waitSignal(star_rating.rating_changed, timeout=1000):
qtbot.mouseClick(star_rating._star_buttons[2], Qt.LeftButton)
assert star_rating.rating() == 3
# Click the first star
with qtbot.waitSignal(star_rating.rating_changed, timeout=1000):
qtbot.mouseClick(star_rating._star_buttons[0], Qt.LeftButton)
assert star_rating.rating() == 1
def test_mouse_hover(self, star_rating, qtbot):
"""Test mouse hover behavior."""
# Set initial rating
star_rating.set_rating(2)
assert star_rating._hovered_star == 0
# Simulate mouse move over the fourth button
btn = star_rating._star_buttons[3]
btn_center = btn.geometry().center()
event = qtbot.mouseMove(star_rating, pos=btn_center)
# Note: _hovered_star should be updated by mouseMoveEvent
# This is a bit tricky to test directly, so we verify the method exists
assert hasattr(star_rating, "mouseMoveEvent")
assert hasattr(star_rating, "leaveEvent")
def test_leave_event(self, star_rating, qtbot):
"""Test that leaving the widget clears hover state."""
star_rating.set_rating(2)
star_rating._hovered_star = 4 # Simulate hover
# Trigger leave event
star_rating.leaveEvent(None)
assert star_rating._hovered_star == 0
assert star_rating.rating() == 2 # Rating should remain unchanged
def test_update_theme_colors(self, star_rating):
"""Test that theme colors are applied correctly."""
assert hasattr(star_rating, "_inactive_color")
assert hasattr(star_rating, "_active_color")
# Colors should be initialized
assert star_rating._inactive_color is not None
assert star_rating._active_color is not None
def test_display_update(self, star_rating):
"""Test that display updates when rating changes."""
star_rating.set_rating(3)
# If this doesn't raise an exception, the display was updated successfully
star_rating._update_display()
class TestFeedbackDialog:
"""Tests for the FeedbackDialog widget."""
def test_initial_state(self, feedback_dialog):
"""Test that FeedbackDialog initializes correctly."""
assert feedback_dialog.windowTitle() == "Feedback"
assert feedback_dialog.isModal() is True
assert feedback_dialog._star_rating is not None
assert feedback_dialog._comment_field is not None
assert feedback_dialog._email_field is not None
assert feedback_dialog._submit_button is not None
assert feedback_dialog._cancel_button is not None
def test_get_feedback_initial(self, feedback_dialog):
"""Test getting feedback from unmodified dialog."""
rating, comment, email = feedback_dialog.get_feedback()
assert rating == 0
assert comment == ""
assert email == ""
def test_set_and_get_rating(self, feedback_dialog):
"""Test setting and getting rating."""
feedback_dialog.set_rating(4)
rating, _, _ = feedback_dialog.get_feedback()
assert rating == 4
def test_set_and_get_comment(self, feedback_dialog):
"""Test setting and getting comment."""
test_comment = "This is a test comment"
feedback_dialog.set_comment(test_comment)
_, comment, _ = feedback_dialog.get_feedback()
assert comment == test_comment
def test_set_and_get_email(self, feedback_dialog):
"""Test setting and getting email."""
test_email = "test@example.com"
feedback_dialog.set_email(test_email)
_, _, email = feedback_dialog.get_feedback()
assert email == test_email
def test_set_all_feedback(self, feedback_dialog):
"""Test setting all feedback fields."""
feedback_dialog.set_rating(5)
feedback_dialog.set_comment("Great widget!")
feedback_dialog.set_email("user@example.com")
rating, comment, email = feedback_dialog.get_feedback()
assert rating == 5
assert comment == "Great widget!"
assert email == "user@example.com"
def test_submit_button_emits_signal(self, feedback_dialog, qtbot):
"""Test that clicking submit emits feedback_submitted signal."""
feedback_dialog.set_rating(3)
feedback_dialog.set_comment("Test feedback")
feedback_dialog.set_email("test@test.com")
with qtbot.waitSignal(feedback_dialog.feedback_submitted, timeout=1000) as blocker:
qtbot.mouseClick(feedback_dialog._submit_button, Qt.LeftButton)
assert blocker.args == [3, "Test feedback", "test@test.com"]
def test_submit_button_accepts_dialog(self, feedback_dialog, qtbot):
"""Test that clicking submit accepts the dialog."""
feedback_dialog.set_rating(4)
qtbot.mouseClick(feedback_dialog._submit_button, Qt.LeftButton)
qtbot.wait(100)
# Dialog should be accepted
assert feedback_dialog.result() == QDialog.DialogCode.Accepted
def test_cancel_button_rejects_dialog(self, feedback_dialog, qtbot):
"""Test that clicking cancel rejects the dialog."""
qtbot.mouseClick(feedback_dialog._cancel_button, Qt.LeftButton)
qtbot.wait(100)
# Dialog should be rejected
assert feedback_dialog.result() == QDialog.DialogCode.Rejected
def test_submit_with_empty_fields(self, feedback_dialog, qtbot):
"""Test submitting with empty fields."""
# Don't set any values
with qtbot.waitSignal(feedback_dialog.feedback_submitted, timeout=1000) as blocker:
qtbot.mouseClick(feedback_dialog._submit_button, Qt.LeftButton)
# Should emit with empty values
assert blocker.args == [0, "", ""]
def test_submit_strips_whitespace(self, feedback_dialog, qtbot):
"""Test that whitespace is stripped from comment and email."""
feedback_dialog.set_comment(" Test comment ")
feedback_dialog.set_email(" test@example.com ")
with qtbot.waitSignal(feedback_dialog.feedback_submitted, timeout=1000) as blocker:
qtbot.mouseClick(feedback_dialog._submit_button, Qt.LeftButton)
rating, comment, email = blocker.args
assert comment == "Test comment"
assert email == "test@example.com"
def test_dialog_has_correct_properties(self, feedback_dialog):
"""Test that dialog has correct class properties."""
assert hasattr(FeedbackDialog, "ICON_NAME")
assert FeedbackDialog.ICON_NAME == "feedback"
assert hasattr(FeedbackDialog, "PLUGIN")
assert FeedbackDialog.PLUGIN is True
def test_comment_field_placeholder(self, feedback_dialog):
"""Test that comment field has placeholder text."""
assert feedback_dialog._comment_field.placeholderText() != ""
def test_email_field_placeholder(self, feedback_dialog):
"""Test that email field has placeholder text."""
assert feedback_dialog._email_field.placeholderText() != ""
def test_submit_button_is_default(self, feedback_dialog):
"""Test that submit button is set as default."""
assert feedback_dialog._submit_button.isDefault() is True
def test_star_rating_embedded_correctly(self, feedback_dialog, qtbot):
"""Test that StarRating widget is properly embedded."""
# Verify we can interact with the embedded star rating
feedback_dialog._star_rating.set_rating(5)
assert feedback_dialog._star_rating.rating() == 5
# Verify rating is reflected in feedback
rating, _, _ = feedback_dialog.get_feedback()
assert rating == 5