diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 55fdf1f1f..45da4c6e1 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -3,6 +3,7 @@ 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.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 @@ def _setup_menu_bar(self): 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 ################################################################################ diff --git a/bec_widgets/widgets/utility/feedback_dialog/__init__.py b/bec_widgets/widgets/utility/feedback_dialog/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/utility/feedback_dialog/feedback_dialog.py b/bec_widgets/widgets/utility/feedback_dialog/feedback_dialog.py new file mode 100644 index 000000000..24ca278b9 --- /dev/null +++ b/bec_widgets/widgets/utility/feedback_dialog/feedback_dialog.py @@ -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()) diff --git a/tests/unit_tests/test_feedback_dialog.py b/tests/unit_tests/test_feedback_dialog.py new file mode 100644 index 000000000..d62a522d8 --- /dev/null +++ b/tests/unit_tests/test_feedback_dialog.py @@ -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