mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-14 11:41:49 +02:00
feat(modular_toolbar): context menu and action bundles
This commit is contained in:
@ -2,17 +2,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Literal
|
from typing import List, Literal, Tuple
|
||||||
|
|
||||||
from bec_qthemes._icon.material_icons import material_icon
|
from bec_qthemes._icon.material_icons import material_icon
|
||||||
from qtpy.QtCore import QSize, Qt
|
from qtpy.QtCore import QSize, Qt
|
||||||
from qtpy.QtGui import QAction, QColor, QIcon
|
from qtpy.QtGui import QAction, QColor, QIcon
|
||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QMainWindow,
|
||||||
QMenu,
|
QMenu,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QToolBar,
|
QToolBar,
|
||||||
@ -31,7 +34,7 @@ class ToolBarAction(ABC):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
|
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
|
||||||
tooltip (bool, optional): The tooltip for the action. Defaults to None.
|
tooltip (str, optional): The tooltip for the action. Defaults to None.
|
||||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -81,15 +84,17 @@ class IconAction(ToolBarAction):
|
|||||||
toolbar.addAction(self.action)
|
toolbar.addAction(self.action)
|
||||||
|
|
||||||
|
|
||||||
class MaterialIconAction:
|
class MaterialIconAction(ToolBarAction):
|
||||||
"""
|
"""
|
||||||
Action with a Material icon for the toolbar.
|
Action with a Material icon for the toolbar.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
icon_path (str, optional): The name of the Material icon. Defaults to None.
|
icon_name (str, optional): The name of the Material icon. Defaults to None.
|
||||||
tooltip (bool, optional): The tooltip for the action. Defaults to None.
|
tooltip (str, optional): The tooltip for the action. Defaults to None.
|
||||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||||
filled (bool, optional): Whether the icon is filled. Defaults to False.
|
filled (bool, optional): Whether the icon is filled. Defaults to False.
|
||||||
|
color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon.
|
||||||
|
Defaults to None.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -100,10 +105,8 @@ class MaterialIconAction:
|
|||||||
filled: bool = False,
|
filled: bool = False,
|
||||||
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
|
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
|
||||||
):
|
):
|
||||||
|
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||||
self.icon_name = icon_name
|
self.icon_name = icon_name
|
||||||
self.tooltip = tooltip
|
|
||||||
self.checkable = checkable
|
|
||||||
self.action = None
|
|
||||||
self.filled = filled
|
self.filled = filled
|
||||||
self.color = color
|
self.color = color
|
||||||
|
|
||||||
@ -114,7 +117,6 @@ class MaterialIconAction:
|
|||||||
toolbar.addAction(self.action)
|
toolbar.addAction(self.action)
|
||||||
|
|
||||||
def get_icon(self):
|
def get_icon(self):
|
||||||
|
|
||||||
icon = material_icon(
|
icon = material_icon(
|
||||||
self.icon_name,
|
self.icon_name,
|
||||||
size=(20, 20),
|
size=(20, 20),
|
||||||
@ -132,7 +134,6 @@ class DeviceSelectionAction(ToolBarAction):
|
|||||||
Args:
|
Args:
|
||||||
label (str): The label for the combobox.
|
label (str): The label for the combobox.
|
||||||
device_combobox (DeviceComboBox): The combobox for selecting the device.
|
device_combobox (DeviceComboBox): The combobox for selecting the device.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, label: str, device_combobox):
|
def __init__(self, label: str, device_combobox):
|
||||||
@ -160,7 +161,6 @@ class WidgetAction(ToolBarAction):
|
|||||||
Args:
|
Args:
|
||||||
label (str|None): The label for the widget.
|
label (str|None): The label for the widget.
|
||||||
widget (QWidget): The widget to be added to the toolbar.
|
widget (QWidget): The widget to be added to the toolbar.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
|
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
|
||||||
@ -219,7 +219,6 @@ class ExpandableMenuAction(ToolBarAction):
|
|||||||
label (str): The label for the menu.
|
label (str): The label for the menu.
|
||||||
actions (dict): A dictionary of actions to populate the menu.
|
actions (dict): A dictionary of actions to populate the menu.
|
||||||
icon_path (str, optional): The path to the icon file. Defaults to None.
|
icon_path (str, optional): The path to the icon file. Defaults to None.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, label: str, actions: dict, icon_path: str = None):
|
def __init__(self, label: str, actions: dict, icon_path: str = None):
|
||||||
@ -259,6 +258,20 @@ class ExpandableMenuAction(ToolBarAction):
|
|||||||
toolbar.addWidget(button)
|
toolbar.addWidget(button)
|
||||||
|
|
||||||
|
|
||||||
|
class Bundle:
|
||||||
|
"""
|
||||||
|
Represents a bundle of toolbar actions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bundle_id (str): Unique identifier for the bundle.
|
||||||
|
actions (List[Tuple[str, ToolBarAction]]): List of tuples containing action IDs and their corresponding ToolBarAction instances.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bundle_id: str, actions: List[Tuple[str, ToolBarAction]]):
|
||||||
|
self.bundle_id = bundle_id
|
||||||
|
self.actions = actions # List of tuples (action_id, ToolBarAction)
|
||||||
|
|
||||||
|
|
||||||
class ModularToolBar(QToolBar):
|
class ModularToolBar(QToolBar):
|
||||||
"""Modular toolbar with optional automatic initialization.
|
"""Modular toolbar with optional automatic initialization.
|
||||||
|
|
||||||
@ -287,10 +300,14 @@ class ModularToolBar(QToolBar):
|
|||||||
# Set the initial orientation
|
# Set the initial orientation
|
||||||
self.set_orientation(orientation)
|
self.set_orientation(orientation)
|
||||||
|
|
||||||
|
# Initialize bundles
|
||||||
|
self.bundles = {}
|
||||||
|
self.toolbar_items = []
|
||||||
|
|
||||||
if actions is not None and target_widget is not None:
|
if actions is not None and target_widget is not None:
|
||||||
self.populate_toolbar(actions, target_widget)
|
self.populate_toolbar(actions, target_widget)
|
||||||
|
|
||||||
def populate_toolbar(self, actions: dict, target_widget):
|
def populate_toolbar(self, actions: dict, target_widget: QWidget):
|
||||||
"""Populates the toolbar with a set of actions.
|
"""Populates the toolbar with a set of actions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -298,9 +315,12 @@ class ModularToolBar(QToolBar):
|
|||||||
target_widget (QWidget): The widget that the actions will target.
|
target_widget (QWidget): The widget that the actions will target.
|
||||||
"""
|
"""
|
||||||
self.clear()
|
self.clear()
|
||||||
|
self.toolbar_items.clear() # Reset the order tracking
|
||||||
for action_id, action in actions.items():
|
for action_id, action in actions.items():
|
||||||
action.add_to_toolbar(self, target_widget)
|
action.add_to_toolbar(self, target_widget)
|
||||||
self.widgets[action_id] = action
|
self.widgets[action_id] = action
|
||||||
|
self.toolbar_items.append(("action", action_id))
|
||||||
|
self.update_separators() # Ensure separators are updated after populating
|
||||||
|
|
||||||
def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"):
|
def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"):
|
||||||
"""
|
"""
|
||||||
@ -345,7 +365,7 @@ class ModularToolBar(QToolBar):
|
|||||||
|
|
||||||
def add_action(self, action_id: str, action: ToolBarAction, target_widget: QWidget):
|
def add_action(self, action_id: str, action: ToolBarAction, target_widget: QWidget):
|
||||||
"""
|
"""
|
||||||
Adds a new action to the toolbar dynamically.
|
Adds a new standalone action to the toolbar dynamically.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
action_id (str): Unique identifier for the action.
|
action_id (str): Unique identifier for the action.
|
||||||
@ -356,6 +376,8 @@ class ModularToolBar(QToolBar):
|
|||||||
raise ValueError(f"Action with ID '{action_id}' already exists.")
|
raise ValueError(f"Action with ID '{action_id}' already exists.")
|
||||||
action.add_to_toolbar(self, target_widget)
|
action.add_to_toolbar(self, target_widget)
|
||||||
self.widgets[action_id] = action
|
self.widgets[action_id] = action
|
||||||
|
self.toolbar_items.append(("action", action_id))
|
||||||
|
self.update_separators() # Update separators after adding the action
|
||||||
|
|
||||||
def hide_action(self, action_id: str):
|
def hide_action(self, action_id: str):
|
||||||
"""
|
"""
|
||||||
@ -369,6 +391,7 @@ class ModularToolBar(QToolBar):
|
|||||||
action = self.widgets[action_id]
|
action = self.widgets[action_id]
|
||||||
if hasattr(action, "action") and isinstance(action.action, QAction):
|
if hasattr(action, "action") and isinstance(action.action, QAction):
|
||||||
action.action.setVisible(False)
|
action.action.setVisible(False)
|
||||||
|
self.update_separators() # Update separators after hiding the action
|
||||||
|
|
||||||
def show_action(self, action_id: str):
|
def show_action(self, action_id: str):
|
||||||
"""
|
"""
|
||||||
@ -382,3 +405,195 @@ class ModularToolBar(QToolBar):
|
|||||||
action = self.widgets[action_id]
|
action = self.widgets[action_id]
|
||||||
if hasattr(action, "action") and isinstance(action.action, QAction):
|
if hasattr(action, "action") and isinstance(action.action, QAction):
|
||||||
action.action.setVisible(True)
|
action.action.setVisible(True)
|
||||||
|
self.update_separators() # Update separators after showing the action
|
||||||
|
|
||||||
|
def add_bundle(self, bundle: Bundle, target_widget: QWidget):
|
||||||
|
"""
|
||||||
|
Adds a bundle of actions to the toolbar, separated by a separator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bundle (Bundle): The bundle to add.
|
||||||
|
target_widget (QWidget): The target widget for the actions.
|
||||||
|
"""
|
||||||
|
if bundle.bundle_id in self.bundles:
|
||||||
|
raise ValueError(f"Bundle with ID '{bundle.bundle_id}' already exists.")
|
||||||
|
|
||||||
|
# Add a separator before the bundle
|
||||||
|
separator = SeparatorAction()
|
||||||
|
separator.add_to_toolbar(self, target_widget)
|
||||||
|
self.toolbar_items.append(("separator", None))
|
||||||
|
|
||||||
|
# Add each action in the bundle
|
||||||
|
for action_id, action in bundle.actions:
|
||||||
|
action.add_to_toolbar(self, target_widget)
|
||||||
|
self.widgets[action_id] = action
|
||||||
|
|
||||||
|
# Register the bundle
|
||||||
|
self.bundles[bundle.bundle_id] = [action_id for action_id, _ in bundle.actions]
|
||||||
|
self.toolbar_items.append(("bundle", bundle.bundle_id))
|
||||||
|
|
||||||
|
self.update_separators() # Update separators after adding the bundle
|
||||||
|
|
||||||
|
def contextMenuEvent(self, event):
|
||||||
|
"""
|
||||||
|
Overrides the context menu event to show a list of toolbar actions with checkboxes and icons, including separators.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event(QContextMenuEvent): The context menu event.
|
||||||
|
"""
|
||||||
|
menu = QMenu(self)
|
||||||
|
|
||||||
|
# Iterate through the toolbar items in order
|
||||||
|
for item_type, identifier in self.toolbar_items:
|
||||||
|
if item_type == "separator":
|
||||||
|
menu.addSeparator()
|
||||||
|
elif item_type == "bundle":
|
||||||
|
# Get actions in the bundle
|
||||||
|
action_ids = self.bundles.get(identifier, [])
|
||||||
|
for action_id in action_ids:
|
||||||
|
toolbar_action = self.widgets.get(action_id)
|
||||||
|
if isinstance(toolbar_action, ToolBarAction) and hasattr(
|
||||||
|
toolbar_action, "action"
|
||||||
|
):
|
||||||
|
qaction = toolbar_action.action
|
||||||
|
if isinstance(qaction, QAction):
|
||||||
|
display_name = qaction.text() or toolbar_action.tooltip or action_id
|
||||||
|
menu_action = QAction(display_name, self)
|
||||||
|
menu_action.setCheckable(True)
|
||||||
|
menu_action.setChecked(qaction.isVisible())
|
||||||
|
menu_action.setData(action_id) # Store the action_id
|
||||||
|
|
||||||
|
# Set the icon if available
|
||||||
|
if qaction.icon() and not qaction.icon().isNull():
|
||||||
|
menu_action.setIcon(qaction.icon())
|
||||||
|
|
||||||
|
menu.addAction(menu_action)
|
||||||
|
elif item_type == "action":
|
||||||
|
# Standalone action
|
||||||
|
toolbar_action = self.widgets.get(identifier)
|
||||||
|
if isinstance(toolbar_action, ToolBarAction) and hasattr(toolbar_action, "action"):
|
||||||
|
qaction = toolbar_action.action
|
||||||
|
if isinstance(qaction, QAction):
|
||||||
|
display_name = qaction.text() or toolbar_action.tooltip or identifier
|
||||||
|
menu_action = QAction(display_name, self)
|
||||||
|
menu_action.setCheckable(True)
|
||||||
|
menu_action.setChecked(qaction.isVisible())
|
||||||
|
menu_action.setData(identifier) # Store the action_id
|
||||||
|
|
||||||
|
# Set the icon if available
|
||||||
|
if qaction.icon() and not qaction.icon().isNull():
|
||||||
|
menu_action.setIcon(qaction.icon())
|
||||||
|
|
||||||
|
menu.addAction(menu_action)
|
||||||
|
|
||||||
|
# Connect the triggered signal after all actions are added
|
||||||
|
menu.triggered.connect(self.handle_menu_triggered)
|
||||||
|
menu.exec_(event.globalPos())
|
||||||
|
|
||||||
|
def handle_menu_triggered(self, action):
|
||||||
|
"""Handles the toggling of toolbar actions from the context menu."""
|
||||||
|
action_id = action.data()
|
||||||
|
if action_id:
|
||||||
|
self.toggle_action_visibility(action_id, action.isChecked())
|
||||||
|
|
||||||
|
def toggle_action_visibility(self, action_id: str, visible: bool):
|
||||||
|
"""
|
||||||
|
Toggles the visibility of a specific action on the toolbar.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action_id(str): Unique identifier for the action to toggle.
|
||||||
|
visible(bool): Whether the action should be visible.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
action = self.widgets[action_id]
|
||||||
|
if hasattr(action, "action") and isinstance(action.action, QAction):
|
||||||
|
action.action.setVisible(visible)
|
||||||
|
self.update_separators() # Update separators after toggling visibility
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_separators(self):
|
||||||
|
"""
|
||||||
|
Hide separators that are adjacent to another separator or have no actions next to them.
|
||||||
|
"""
|
||||||
|
toolbar_actions = self.actions()
|
||||||
|
|
||||||
|
for i, action in enumerate(toolbar_actions):
|
||||||
|
if action.isSeparator():
|
||||||
|
# Find the previous visible action
|
||||||
|
prev_visible = None
|
||||||
|
for j in range(i - 1, -1, -1):
|
||||||
|
if toolbar_actions[j].isVisible():
|
||||||
|
prev_visible = toolbar_actions[j]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Find the next visible action
|
||||||
|
next_visible = None
|
||||||
|
for j in range(i + 1, len(toolbar_actions)):
|
||||||
|
if toolbar_actions[j].isVisible():
|
||||||
|
next_visible = toolbar_actions[j]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Determine if the separator should be hidden
|
||||||
|
# Hide if both previous and next visible actions are separators or non-existent
|
||||||
|
if (prev_visible is None or prev_visible.isSeparator()) and (
|
||||||
|
next_visible is None or next_visible.isSeparator()
|
||||||
|
):
|
||||||
|
action.setVisible(False)
|
||||||
|
else:
|
||||||
|
action.setVisible(True)
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow): # pragma: no cover
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.central_widget = QWidget()
|
||||||
|
self.setCentralWidget(self.central_widget)
|
||||||
|
|
||||||
|
# Initialize the ModularToolBar
|
||||||
|
self.toolbar = ModularToolBar(parent=self, target_widget=self)
|
||||||
|
self.addToolBar(self.toolbar)
|
||||||
|
|
||||||
|
# Define individual MaterialIconActions for the first bundle
|
||||||
|
home_action = MaterialIconAction(icon_name="home", tooltip="Home", checkable=True)
|
||||||
|
settings_action = MaterialIconAction(
|
||||||
|
icon_name="settings", tooltip="Settings", checkable=True
|
||||||
|
)
|
||||||
|
profile_action = MaterialIconAction(icon_name="person", tooltip="Profile", checkable=True)
|
||||||
|
|
||||||
|
# Create the first Bundle with these actions
|
||||||
|
main_actions_bundle = Bundle(
|
||||||
|
bundle_id="main_actions",
|
||||||
|
actions=[
|
||||||
|
("home_action", home_action),
|
||||||
|
("settings_action", settings_action),
|
||||||
|
("profile_action", profile_action),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add the first bundle to the toolbar
|
||||||
|
self.toolbar.add_bundle(main_actions_bundle, target_widget=self)
|
||||||
|
|
||||||
|
# Define individual MaterialIconActions for the second bundle
|
||||||
|
search_action = MaterialIconAction(icon_name="search", tooltip="Search", checkable=True)
|
||||||
|
help_action = MaterialIconAction(icon_name="help", tooltip="Help", checkable=True)
|
||||||
|
|
||||||
|
# Create the second Bundle with these actions
|
||||||
|
secondary_actions_bundle = Bundle(
|
||||||
|
bundle_id="secondary_actions",
|
||||||
|
actions=[("search_action", search_action), ("help_action", help_action)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add the second bundle to the toolbar
|
||||||
|
self.toolbar.add_bundle(secondary_actions_bundle, target_widget=self)
|
||||||
|
|
||||||
|
# Define a standalone action
|
||||||
|
info_action = MaterialIconAction(icon_name="info", tooltip="Info", checkable=True)
|
||||||
|
self.toolbar.add_action("info_action", info_action, target_widget=self)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
main_window = MainWindow()
|
||||||
|
main_window.show()
|
||||||
|
sys.exit(app.exec_())
|
||||||
|
@ -5,6 +5,7 @@ from qtpy.QtCore import Qt
|
|||||||
from qtpy.QtWidgets import QComboBox, QLabel, QToolButton, QWidget
|
from qtpy.QtWidgets import QComboBox, QLabel, QToolButton, QWidget
|
||||||
|
|
||||||
from bec_widgets.qt_utils.toolbar import (
|
from bec_widgets.qt_utils.toolbar import (
|
||||||
|
Bundle,
|
||||||
DeviceSelectionAction,
|
DeviceSelectionAction,
|
||||||
ExpandableMenuAction,
|
ExpandableMenuAction,
|
||||||
IconAction,
|
IconAction,
|
||||||
@ -277,3 +278,36 @@ def test_show_action_nonexistent(toolbar_fixture):
|
|||||||
with pytest.raises(ValueError) as excinfo:
|
with pytest.raises(ValueError) as excinfo:
|
||||||
toolbar.show_action("nonexistent_action")
|
toolbar.show_action("nonexistent_action")
|
||||||
assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value)
|
assert "Action with ID 'nonexistent_action' does not exist." in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_bundle(toolbar_fixture, dummy_widget, icon_action, material_icon_action):
|
||||||
|
toolbar = toolbar_fixture
|
||||||
|
bundle = Bundle(
|
||||||
|
bundle_id="test_bundle",
|
||||||
|
actions=[
|
||||||
|
("icon_action_in_bundle", icon_action),
|
||||||
|
("material_icon_in_bundle", material_icon_action),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
toolbar.add_bundle(bundle, dummy_widget)
|
||||||
|
assert "test_bundle" in toolbar.bundles
|
||||||
|
assert "icon_action_in_bundle" in toolbar.widgets
|
||||||
|
assert "material_icon_in_bundle" in toolbar.widgets
|
||||||
|
assert icon_action.action in toolbar.actions()
|
||||||
|
assert material_icon_action.action in toolbar.actions()
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_orientation(dummy_widget):
|
||||||
|
toolbar = ModularToolBar(target_widget=dummy_widget, orientation="horizontal")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
toolbar.set_orientation("diagonal")
|
||||||
|
|
||||||
|
|
||||||
|
def test_widgetaction_calculate_minimum_width(qtbot):
|
||||||
|
combo = QComboBox()
|
||||||
|
combo.addItems(["Short", "Longer Item", "The Longest Item In Combo"])
|
||||||
|
widget_action = WidgetAction(label="Test", widget=combo)
|
||||||
|
width = widget_action.calculate_minimum_width(combo)
|
||||||
|
assert width > 0
|
||||||
|
# Width should be large enough to accommodate the longest item plus additional space
|
||||||
|
assert width > 100
|
||||||
|
Reference in New Issue
Block a user