Implemented Delete feature in DataGridsManager.py. There is still a bug as DBEngine.delete is not implemented
Improved readability for tests using matcher
This commit is contained in:
@@ -1022,12 +1022,16 @@ class DataGrid(MultipleInstance):
|
|||||||
|
|
||||||
def delete(self):
|
def delete(self):
|
||||||
"""
|
"""
|
||||||
remove DBEngine entries
|
Remove DBEngine entries for this DataGrid.
|
||||||
:return:
|
|
||||||
|
Deletes all persistent state associated with this grid:
|
||||||
|
- State (columns, rows, selection, formatting, etc.)
|
||||||
|
- Settings (namespace, name, visibility options, etc.)
|
||||||
|
- DataFrame store (data and fast access structures)
|
||||||
"""
|
"""
|
||||||
# self._state.delete()
|
self._state.delete()
|
||||||
# self._settings.delete()
|
self._settings.delete()
|
||||||
pass
|
self._df_store.delete()
|
||||||
|
|
||||||
def __ft__(self):
|
def __ft__(self):
|
||||||
return self.render()
|
return self.render()
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ class Commands(BaseCommands):
|
|||||||
self._owner.select_document,
|
self._owner.select_document,
|
||||||
key="SelectNode")
|
key="SelectNode")
|
||||||
|
|
||||||
|
def delete_grid(self):
|
||||||
|
return Command("DeleteGrid",
|
||||||
|
"Delete grid",
|
||||||
|
self._owner,
|
||||||
|
self._owner.delete_grid,
|
||||||
|
key="DeleteNode")
|
||||||
|
|
||||||
|
|
||||||
class DataGridsManager(SingleInstance, DatagridMetadataProvider):
|
class DataGridsManager(SingleInstance, DatagridMetadataProvider):
|
||||||
|
|
||||||
@@ -85,6 +92,7 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
|
|||||||
self._state = DataGridsState(self)
|
self._state = DataGridsState(self)
|
||||||
self._tree = self._mk_tree()
|
self._tree = self._mk_tree()
|
||||||
self._tree.bind_command("SelectNode", self.commands.show_document())
|
self._tree.bind_command("SelectNode", self.commands.show_document())
|
||||||
|
self._tree.bind_command("DeleteNode", self.commands.delete_grid(), when="before")
|
||||||
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager, None)
|
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager, None)
|
||||||
self._registry = DataGridsRegistry(parent)
|
self._registry = DataGridsRegistry(parent)
|
||||||
|
|
||||||
@@ -241,6 +249,55 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
|
|||||||
# the selected node is not a document (it's a folder)
|
# the selected node is not a document (it's a folder)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def delete_grid(self, node_id):
|
||||||
|
"""
|
||||||
|
Delete a grid and all its associated resources.
|
||||||
|
|
||||||
|
This method is called BEFORE TreeView._delete_node() to ensure we can
|
||||||
|
access the node's bag to retrieve the document_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node_id: ID of the TreeView node to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None (TreeView will handle the node removal)
|
||||||
|
"""
|
||||||
|
document_id = self._tree.get_bag(node_id)
|
||||||
|
if document_id is None:
|
||||||
|
# Node is a folder, not a document - nothing to clean up
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find the document
|
||||||
|
document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
|
||||||
|
|
||||||
|
# Get the DataGrid instance
|
||||||
|
dg = DataGrid(self, _id=document.datagrid_id)
|
||||||
|
|
||||||
|
# Close the tab
|
||||||
|
self._tabs_manager.close_tab(document.tab_id)
|
||||||
|
|
||||||
|
# Remove from registry
|
||||||
|
self._registry.remove(document.datagrid_id)
|
||||||
|
|
||||||
|
# Clean up DataGrid (delete DBEngine entries)
|
||||||
|
dg.delete()
|
||||||
|
|
||||||
|
# Remove from InstancesManager
|
||||||
|
InstancesManager.remove(self._session, document.datagrid_id)
|
||||||
|
|
||||||
|
# Remove DocumentDefinition from state
|
||||||
|
self._state.elements = [d for d in self._state.elements if d.document_id != document_id]
|
||||||
|
self._state.save()
|
||||||
|
|
||||||
|
except StopIteration:
|
||||||
|
# Document not found - already deleted or invalid state
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Note: We do NOT call tree._delete_node() here because TreeView will do it
|
||||||
|
# automatically after this "before" bound command completes
|
||||||
|
return None
|
||||||
|
|
||||||
def create_tab_content(self, tab_id):
|
def create_tab_content(self, tab_id):
|
||||||
"""
|
"""
|
||||||
Recreate the content for a tab managed by this DataGridsManager.
|
Recreate the content for a tab managed by this DataGridsManager.
|
||||||
|
|||||||
@@ -2,18 +2,23 @@ from myfasthtml.core.constants import ColumnType
|
|||||||
from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \
|
from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \
|
||||||
number_row20_regular
|
number_row20_regular
|
||||||
from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \
|
from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \
|
||||||
checkbox_checked20_filled, math_formula16_regular, folder20_regular
|
checkbox_checked20_filled, math_formula16_regular, folder20_regular, document20_regular
|
||||||
from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular
|
from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular
|
||||||
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular
|
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular
|
||||||
|
|
||||||
default_icons = {
|
default_icons = {
|
||||||
|
# default type icons
|
||||||
None: question20_regular,
|
None: question20_regular,
|
||||||
True: checkbox_checked20_regular,
|
True: checkbox_checked20_regular,
|
||||||
False: checkbox_unchecked20_regular,
|
False: checkbox_unchecked20_regular,
|
||||||
|
|
||||||
"Brain": brain_circuit20_regular,
|
"Brain": brain_circuit20_regular,
|
||||||
"TreeViewFolder" : folder20_regular,
|
|
||||||
|
|
||||||
|
# TreeView icons
|
||||||
|
"TreeViewFolder": folder20_regular,
|
||||||
|
"TreeViewFile": document20_regular,
|
||||||
|
|
||||||
|
# Datagrid column icons
|
||||||
ColumnType.RowIndex: number_symbol20_regular,
|
ColumnType.RowIndex: number_symbol20_regular,
|
||||||
ColumnType.Text: text_field20_regular,
|
ColumnType.Text: text_field20_regular,
|
||||||
ColumnType.Number: number_row20_regular,
|
ColumnType.Number: number_row20_regular,
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ class TreeView(MultipleInstance):
|
|||||||
if self.conf.icons:
|
if self.conf.icons:
|
||||||
self._state.icon_config = self.conf.icons
|
self._state.icon_config = self.conf.icons
|
||||||
else:
|
else:
|
||||||
self._state.icon_config = {"folder": "TreeViewFolder"}
|
self._state.icon_config = {"folder": "TreeViewFolder", "file": "TreeViewFile"}
|
||||||
|
|
||||||
def set_icon_config(self, config: dict[str, str]):
|
def set_icon_config(self, config: dict[str, str]):
|
||||||
"""
|
"""
|
||||||
@@ -458,8 +458,9 @@ class TreeView(MultipleInstance):
|
|||||||
), command=CommandTemplate("TreeView.SaveRename", self.commands.save_rename, args=[node_id]))
|
), command=CommandTemplate("TreeView.SaveRename", self.commands.save_rename, args=[node_id]))
|
||||||
else:
|
else:
|
||||||
label_element = mk.label(
|
label_element = mk.label(
|
||||||
Span(node.label, cls="mf-treenode-label text-sm"),
|
node.label,
|
||||||
icon=icon,
|
icon=icon,
|
||||||
|
cls="mf-treenode-label text-sm",
|
||||||
enable_button=False,
|
enable_button=False,
|
||||||
command=self.commands.select_node(node_id)
|
command=self.commands.select_node(node_id)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class mk:
|
|||||||
kwargs)
|
kwargs)
|
||||||
icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None
|
icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None
|
||||||
text_part = Span(text, cls=f"text-{size} truncate")
|
text_part = Span(text, cls=f"text-{size} truncate")
|
||||||
return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
|
return mk.mk(Span(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def convert_size(size: str):
|
def convert_size(size: str):
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ class DataGridsRegistry(SingleInstance):
|
|||||||
all_entries[datagrid_id] = (namespace, name)
|
all_entries[datagrid_id] = (namespace, name)
|
||||||
self._db_manager.save(DATAGRIDS_REGISTRY_ENTRY_KEY, all_entries)
|
self._db_manager.save(DATAGRIDS_REGISTRY_ENTRY_KEY, all_entries)
|
||||||
|
|
||||||
|
def remove(self, datagrid_id):
|
||||||
|
"""
|
||||||
|
Remove a datagrid from the registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datagrid_id: ID of the datagrid to remove
|
||||||
|
"""
|
||||||
|
all_entries = self._db_manager.load(DATAGRIDS_REGISTRY_ENTRY_KEY)
|
||||||
|
if datagrid_id in all_entries:
|
||||||
|
del all_entries[datagrid_id]
|
||||||
|
self._db_manager.save(DATAGRIDS_REGISTRY_ENTRY_KEY, all_entries)
|
||||||
|
|
||||||
def get_all_tables(self):
|
def get_all_tables(self):
|
||||||
all_entries = self._get_all_entries()
|
all_entries = self._get_all_entries()
|
||||||
return [f"{namespace}.{name}" for (namespace, name) in all_entries.values()]
|
return [f"{namespace}.{name}" for (namespace, name) in all_entries.values()]
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import inspect
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Literal
|
||||||
|
|
||||||
from myutils.observable import NotObservableError, ObservableResultCollector
|
from myutils.observable import NotObservableError, ObservableResultCollector
|
||||||
|
|
||||||
@@ -15,6 +16,19 @@ logger = logging.getLogger("Commands")
|
|||||||
AUTO_SWAP_OOB = "__auto_swap_oob__"
|
AUTO_SWAP_OOB = "__auto_swap_oob__"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BoundCommand:
|
||||||
|
"""
|
||||||
|
Represents a command bound to another command.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
command: The command to execute
|
||||||
|
when: When to execute the bound command ("before" or "after" the main command)
|
||||||
|
"""
|
||||||
|
command: 'Command'
|
||||||
|
when: Literal["before", "after"] = "after"
|
||||||
|
|
||||||
|
|
||||||
class Command:
|
class Command:
|
||||||
"""
|
"""
|
||||||
Represents the base command class for defining executable actions.
|
Represents the base command class for defining executable actions.
|
||||||
@@ -128,19 +142,31 @@ class Command:
|
|||||||
def execute(self, client_response: dict = None):
|
def execute(self, client_response: dict = None):
|
||||||
logger.debug(f"Executing command {self.name} with arguments {client_response=}")
|
logger.debug(f"Executing command {self.name} with arguments {client_response=}")
|
||||||
with ObservableResultCollector(self._bindings) as collector:
|
with ObservableResultCollector(self._bindings) as collector:
|
||||||
|
# Execute "before" bound commands
|
||||||
|
ret_from_before_commands = []
|
||||||
|
if self.owner:
|
||||||
|
before_commands = [bc for bc in self.owner.get_bound_commands(self.name) if bc.when == "before"]
|
||||||
|
for bound_cmd in before_commands:
|
||||||
|
logger.debug(f" will execute bound command {bound_cmd.command.name} BEFORE...")
|
||||||
|
r = bound_cmd.command.execute(client_response)
|
||||||
|
ret_from_before_commands.append(r)
|
||||||
|
|
||||||
|
# Execute main callback
|
||||||
kwargs = self._create_kwargs(self.default_kwargs,
|
kwargs = self._create_kwargs(self.default_kwargs,
|
||||||
client_response,
|
client_response,
|
||||||
{"client_response": client_response or {}})
|
{"client_response": client_response or {}})
|
||||||
ret = self.callback(*self.default_args, **kwargs)
|
ret = self.callback(*self.default_args, **kwargs)
|
||||||
|
|
||||||
ret_from_bound_commands = []
|
# Execute "after" bound commands
|
||||||
|
ret_from_after_commands = []
|
||||||
if self.owner:
|
if self.owner:
|
||||||
for command in self.owner.get_bound_commands(self.name):
|
after_commands = [bc for bc in self.owner.get_bound_commands(self.name) if bc.when == "after"]
|
||||||
logger.debug(f" will execute bound command {command.name}...")
|
for bound_cmd in after_commands:
|
||||||
r = command.execute(client_response)
|
logger.debug(f" will execute bound command {bound_cmd.command.name} AFTER...")
|
||||||
ret_from_bound_commands.append(r) # it will be flatten if needed later
|
r = bound_cmd.command.execute(client_response)
|
||||||
|
ret_from_after_commands.append(r)
|
||||||
|
|
||||||
all_ret = flatten(ret, ret_from_bound_commands, collector.results)
|
all_ret = flatten(ret, ret_from_before_commands, ret_from_after_commands, collector.results)
|
||||||
|
|
||||||
# Set the hx-swap-oob attribute on all elements returned by the callback
|
# Set the hx-swap-oob attribute on all elements returned by the callback
|
||||||
if self._htmx_extra[AUTO_SWAP_OOB]:
|
if self._htmx_extra[AUTO_SWAP_OOB]:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import uuid
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.controls.helpers import Ids
|
||||||
|
from myfasthtml.core.commands import BoundCommand
|
||||||
from myfasthtml.core.constants import NO_DEFAULT_VALUE
|
from myfasthtml.core.constants import NO_DEFAULT_VALUE
|
||||||
from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal
|
from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal
|
||||||
|
|
||||||
@@ -109,9 +110,18 @@ class BaseInstance:
|
|||||||
parent = self.get_parent()
|
parent = self.get_parent()
|
||||||
return parent.get_full_id() if parent else None
|
return parent.get_full_id() if parent else None
|
||||||
|
|
||||||
def bind_command(self, command, command_to_bind):
|
def bind_command(self, command, command_to_bind, when="after"):
|
||||||
|
"""
|
||||||
|
Bind a command to another command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Command name or Command instance to bind to
|
||||||
|
command_to_bind: Command to execute when the main command is triggered
|
||||||
|
when: "before" or "after" - when to execute the bound command (default: "after")
|
||||||
|
"""
|
||||||
command_name = command.name if hasattr(command, "name") else command
|
command_name = command.name if hasattr(command, "name") else command
|
||||||
self._bound_commands.setdefault(command_name, []).append(command_to_bind)
|
bound = BoundCommand(command=command_to_bind, when=when)
|
||||||
|
self._bound_commands.setdefault(command_name, []).append(bound)
|
||||||
|
|
||||||
def get_bound_commands(self, command_name):
|
def get_bound_commands(self, command_name):
|
||||||
return self._bound_commands.get(command_name, [])
|
return self._bound_commands.get(command_name, [])
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any, Literal
|
||||||
|
|
||||||
from fastcore.basics import NotStr
|
from fastcore.basics import NotStr
|
||||||
from fastcore.xml import FT
|
from fastcore.xml import FT
|
||||||
|
from fasthtml.components import Span
|
||||||
|
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.utils import quoted_str, snake_to_pascal
|
from myfasthtml.core.utils import quoted_str, snake_to_pascal
|
||||||
@@ -185,8 +186,27 @@ class TestObject:
|
|||||||
self.attrs = kwargs
|
self.attrs = kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class TestLabel(TestObject):
|
||||||
|
def __init__(self, label: str, icon: str = None, command=None):
|
||||||
|
super().__init__("span")
|
||||||
|
self.label = label
|
||||||
|
self.icon = snake_to_pascal(icon) if (icon and icon[0].islower()) else icon
|
||||||
|
|
||||||
|
self.children = []
|
||||||
|
if self.icon:
|
||||||
|
self.children.append(TestIcon(self.icon, wrapper="span"))
|
||||||
|
self.children.append(Span(label))
|
||||||
|
|
||||||
|
if command:
|
||||||
|
self.attrs |= command.get_htmx_params()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
icon_str = f"<svg name='{self.icon}'" if self.icon else ""
|
||||||
|
return f'<span>{icon_str}<span>{self.label}</span></span>'
|
||||||
|
|
||||||
|
|
||||||
class TestIcon(TestObject):
|
class TestIcon(TestObject):
|
||||||
def __init__(self, name: Optional[str] = '', wrapper="div", command=None):
|
def __init__(self, name: Optional[str] = '', wrapper: Literal["div", "span"] = "div", command=None):
|
||||||
super().__init__(wrapper)
|
super().__init__(wrapper)
|
||||||
self.wrapper = wrapper
|
self.wrapper = wrapper
|
||||||
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
|
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
|
||||||
@@ -239,18 +259,28 @@ class Skip:
|
|||||||
def _get_type(x):
|
def _get_type(x):
|
||||||
if hasattr(x, "tag"):
|
if hasattr(x, "tag"):
|
||||||
return x.tag
|
return x.tag
|
||||||
if isinstance(x, (TestObject, TestIcon)):
|
if isinstance(x, TestObject):
|
||||||
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
|
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
|
||||||
return type(x).__name__
|
return type(x).__name__
|
||||||
|
|
||||||
|
|
||||||
def _get_attr(x, attr):
|
def _get_attr(x, attr):
|
||||||
|
if isinstance(x, TestObject) and "s" in x.attrs and isinstance(x.attrs["s"], Regex):
|
||||||
|
return x.attrs["s"].value + " />"
|
||||||
|
|
||||||
if hasattr(x, "attrs"):
|
if hasattr(x, "attrs"):
|
||||||
return x.attrs.get(attr, MISSING_ATTR)
|
return x.attrs.get(attr, MISSING_ATTR)
|
||||||
|
|
||||||
if not hasattr(x, attr):
|
if not hasattr(x, attr):
|
||||||
return MISSING_ATTR
|
return MISSING_ATTR
|
||||||
|
|
||||||
|
if isinstance(x, NotStr) and attr == "s":
|
||||||
|
# Special case for NotStr: return the name of the svg
|
||||||
|
svg = getattr(x, attr, MISSING_ATTR)
|
||||||
|
match = re.search(r'name\s*=\s*["\']([^"\']+)["\']', svg)
|
||||||
|
if match:
|
||||||
|
return f'<svg name="{match.group(1)}" />'
|
||||||
|
|
||||||
return getattr(x, attr, MISSING_ATTR)
|
return getattr(x, attr, MISSING_ATTR)
|
||||||
|
|
||||||
|
|
||||||
@@ -453,9 +483,9 @@ class ErrorComparisonOutput:
|
|||||||
expected = self.adjust(expected, actual)
|
expected = self.adjust(expected, actual)
|
||||||
|
|
||||||
actual_max_length = len(max(actual, key=len))
|
actual_max_length = len(max(actual, key=len))
|
||||||
# expected_max_length = len(max(expected, key=len))
|
expected_max_length = len(max(expected, key=len))
|
||||||
|
|
||||||
output = []
|
output = [f"{' Actual ':=^{actual_max_length}} | {' Expected ':=^{expected_max_length}}"]
|
||||||
for a, e in zip(actual, expected):
|
for a, e in zip(actual, expected):
|
||||||
line = f"{a:<{actual_max_length}} | {e}".rstrip()
|
line = f"{a:<{actual_max_length}} | {e}".rstrip()
|
||||||
output.append(line)
|
output.append(line)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from fasthtml.components import *
|
|||||||
from myfasthtml.controls.Keyboard import Keyboard
|
from myfasthtml.controls.Keyboard import Keyboard
|
||||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||||
from myfasthtml.test.matcher import matches, TestObject, TestCommand, TestIcon, find_one, find, Contains, \
|
from myfasthtml.test.matcher import matches, TestObject, TestCommand, TestIcon, find_one, find, Contains, \
|
||||||
DoesNotContain
|
DoesNotContain, TestLabel
|
||||||
from .conftest import root_instance
|
from .conftest import root_instance
|
||||||
|
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ class TestTreeviewBehaviour:
|
|||||||
assert state.opened == []
|
assert state.opened == []
|
||||||
assert state.selected is None
|
assert state.selected is None
|
||||||
assert state.editing is None
|
assert state.editing is None
|
||||||
assert state.icon_config == {}
|
assert state.icon_config == {'file': 'TreeViewFile', 'folder': 'TreeViewFolder'}
|
||||||
|
|
||||||
def test_i_can_create_empty_treeview(self, root_instance):
|
def test_i_can_create_empty_treeview(self, root_instance):
|
||||||
"""Test creating an empty TreeView."""
|
"""Test creating an empty TreeView."""
|
||||||
@@ -674,7 +674,7 @@ class TestTreeViewRender:
|
|||||||
Div(
|
Div(
|
||||||
Div(
|
Div(
|
||||||
TestIcon("chevron_right20_regular"), # Collapsed toggle icon
|
TestIcon("chevron_right20_regular"), # Collapsed toggle icon
|
||||||
Span("Parent"), # Label
|
TestLabel("Parent", icon="folder20_regular"),
|
||||||
Div( # Action buttons
|
Div( # Action buttons
|
||||||
TestIcon("add_circle20_regular"),
|
TestIcon("add_circle20_regular"),
|
||||||
TestIcon("edit20_regular"),
|
TestIcon("edit20_regular"),
|
||||||
@@ -736,7 +736,7 @@ class TestTreeViewRender:
|
|||||||
expected_child_container = Div(
|
expected_child_container = Div(
|
||||||
Div(
|
Div(
|
||||||
None, # No icon for leaf nodes
|
None, # No icon for leaf nodes
|
||||||
Span("Child1"),
|
TestLabel("Child1", icon="Document20Regular"),
|
||||||
Div(), # action buttons
|
Div(), # action buttons
|
||||||
cls=Contains("mf-treenode")
|
cls=Contains("mf-treenode")
|
||||||
),
|
),
|
||||||
@@ -764,7 +764,7 @@ class TestTreeViewRender:
|
|||||||
expected = Div(
|
expected = Div(
|
||||||
Div(
|
Div(
|
||||||
None, # No icon for leaf nodes
|
None, # No icon for leaf nodes
|
||||||
Span("Leaf Node"), # Label
|
TestLabel("Leaf Node", icon="Document20Regular"), # Label
|
||||||
Div(), # Action buttons still present
|
Div(), # Action buttons still present
|
||||||
),
|
),
|
||||||
cls=Contains("mf-treenode"),
|
cls=Contains("mf-treenode"),
|
||||||
@@ -792,7 +792,7 @@ class TestTreeViewRender:
|
|||||||
expected = Div(
|
expected = Div(
|
||||||
Div(
|
Div(
|
||||||
None, # No icon for leaf nodes
|
None, # No icon for leaf nodes
|
||||||
Span("Selected Node"),
|
TestLabel("Selected Node", icon="Document20Regular"),
|
||||||
Div(), # Action buttons
|
Div(), # Action buttons
|
||||||
cls=Contains("mf-treenode", "selected")
|
cls=Contains("mf-treenode", "selected")
|
||||||
),
|
),
|
||||||
@@ -872,7 +872,7 @@ class TestTreeViewRender:
|
|||||||
root_expected = Div(
|
root_expected = Div(
|
||||||
Div(
|
Div(
|
||||||
TestIcon("chevron_down20_regular"), # Expanded icon
|
TestIcon("chevron_down20_regular"), # Expanded icon
|
||||||
Span("Root"),
|
TestLabel("Root", icon="Folder20Regular"),
|
||||||
Div(), # Action buttons
|
Div(), # Action buttons
|
||||||
cls=Contains("mf-treenode"),
|
cls=Contains("mf-treenode"),
|
||||||
style=Contains("padding-left: 0px")
|
style=Contains("padding-left: 0px")
|
||||||
@@ -887,10 +887,10 @@ class TestTreeViewRender:
|
|||||||
child_expected = Div(
|
child_expected = Div(
|
||||||
Div(
|
Div(
|
||||||
TestIcon("chevron_down20_regular"), # Expanded icon
|
TestIcon("chevron_down20_regular"), # Expanded icon
|
||||||
Span("Child"),
|
TestLabel("Child", icon="Folder20Regular"),
|
||||||
Div(), # Action buttons
|
Div(), # Action buttons
|
||||||
cls=Contains("mf-treenode"),
|
cls=Contains("mf-treenode"),
|
||||||
style=Contains("padding-left: 20px")
|
style=Contains("padding-left: 45px")
|
||||||
),
|
),
|
||||||
cls="mf-treenode-container",
|
cls="mf-treenode-container",
|
||||||
data_node_id=child.id
|
data_node_id=child.id
|
||||||
@@ -902,10 +902,10 @@ class TestTreeViewRender:
|
|||||||
grandchild_expected = Div(
|
grandchild_expected = Div(
|
||||||
Div(
|
Div(
|
||||||
None, # No icon for leaf nodes
|
None, # No icon for leaf nodes
|
||||||
Span("Grandchild"),
|
TestLabel("Grandchild", icon="Document20Regular"),
|
||||||
Div(), # Action buttons
|
Div(), # Action buttons
|
||||||
cls=Contains("mf-treenode"),
|
cls=Contains("mf-treenode"),
|
||||||
style=Contains("padding-left: 40px")
|
style=Contains("padding-left: 90px")
|
||||||
),
|
),
|
||||||
cls="mf-treenode-container",
|
cls="mf-treenode-container",
|
||||||
data_node_id=grandchild.id
|
data_node_id=grandchild.id
|
||||||
@@ -1071,7 +1071,7 @@ class TestTreeViewRender:
|
|||||||
expected_root1 = Div(
|
expected_root1 = Div(
|
||||||
Div(
|
Div(
|
||||||
None, # No icon for leaf nodes
|
None, # No icon for leaf nodes
|
||||||
Span("Root 1"),
|
TestLabel("Root 1", icon="Folder20Regular"),
|
||||||
Div(), # Action buttons
|
Div(), # Action buttons
|
||||||
cls=Contains("mf-treenode")
|
cls=Contains("mf-treenode")
|
||||||
),
|
),
|
||||||
@@ -1082,7 +1082,7 @@ class TestTreeViewRender:
|
|||||||
expected_root2 = Div(
|
expected_root2 = Div(
|
||||||
Div(
|
Div(
|
||||||
None, # No icon for leaf nodes
|
None, # No icon for leaf nodes
|
||||||
Span("Root 2"),
|
TestLabel("Root 2", icon="Folder20Regular"),
|
||||||
Div(), # Action buttons
|
Div(), # Action buttons
|
||||||
cls=Contains("mf-treenode")
|
cls=Contains("mf-treenode")
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import pytest
|
|||||||
from fasthtml.components import Button, Div
|
from fasthtml.components import Button, Div
|
||||||
from myutils.observable import make_observable, bind
|
from myutils.observable import make_observable, bind
|
||||||
|
|
||||||
from myfasthtml.core.commands import Command, CommandsManager, LambdaCommand
|
from myfasthtml.core.commands import Command, CommandsManager, LambdaCommand, BoundCommand
|
||||||
from myfasthtml.core.constants import ROUTE_ROOT, Routes
|
from myfasthtml.core.constants import ROUTE_ROOT, Routes
|
||||||
|
from myfasthtml.core.instances import BaseInstance
|
||||||
from myfasthtml.test.matcher import matches
|
from myfasthtml.test.matcher import matches
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +25,20 @@ def reset_command_manager():
|
|||||||
CommandsManager.reset()
|
CommandsManager.reset()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session():
|
||||||
|
"""Create a test session."""
|
||||||
|
return {"user_info": {"id": "test-user-123"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def owner(session):
|
||||||
|
"""Create a BaseInstance owner for testing bind_command."""
|
||||||
|
res = BaseInstance(parent=None, session=session, _id="test-owner")
|
||||||
|
res._bound_commands.clear()
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
class TestCommandDefault:
|
class TestCommandDefault:
|
||||||
|
|
||||||
def test_i_can_create_a_command_with_no_params(self):
|
def test_i_can_create_a_command_with_no_params(self):
|
||||||
@@ -199,6 +214,187 @@ class TestCommandExecute:
|
|||||||
assert "hx-swap-oob" not in res[1].attrs
|
assert "hx-swap-oob" not in res[1].attrs
|
||||||
assert "hx-swap-oob" not in res[3].attrs
|
assert "hx-swap-oob" not in res[3].attrs
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("when", ["before", "after"])
|
||||||
|
def test_i_can_execute_bound_command_with_when(self, owner, when):
|
||||||
|
"""Test that bound commands execute before or after main callback based on when parameter."""
|
||||||
|
execution_order = []
|
||||||
|
|
||||||
|
def main_callback():
|
||||||
|
execution_order.append("main")
|
||||||
|
return Div(id="main")
|
||||||
|
|
||||||
|
def bound_callback():
|
||||||
|
execution_order.append(when)
|
||||||
|
return Div(id=when)
|
||||||
|
|
||||||
|
main_command = Command('main', 'Main command', owner, main_callback)
|
||||||
|
bound_command = Command('bound', 'Bound command', owner, bound_callback)
|
||||||
|
|
||||||
|
owner.bind_command(main_command, bound_command, when=when)
|
||||||
|
|
||||||
|
res = main_command.execute()
|
||||||
|
|
||||||
|
if when == "before":
|
||||||
|
assert execution_order == ["before", "main"]
|
||||||
|
else:
|
||||||
|
assert execution_order == ["main", "after"]
|
||||||
|
|
||||||
|
assert isinstance(res, list)
|
||||||
|
|
||||||
|
def test_i_can_execute_multiple_bound_commands_in_correct_order(self, owner):
|
||||||
|
"""Test that multiple bound commands execute in correct order: before1, before2, main, after1, after2."""
|
||||||
|
execution_order = []
|
||||||
|
|
||||||
|
def main_callback():
|
||||||
|
execution_order.append("main")
|
||||||
|
return Div(id="main")
|
||||||
|
|
||||||
|
def before1_callback():
|
||||||
|
execution_order.append("before1")
|
||||||
|
return Div(id="before1")
|
||||||
|
|
||||||
|
def before2_callback():
|
||||||
|
execution_order.append("before2")
|
||||||
|
return Div(id="before2")
|
||||||
|
|
||||||
|
def after1_callback():
|
||||||
|
execution_order.append("after1")
|
||||||
|
return Div(id="after1")
|
||||||
|
|
||||||
|
def after2_callback():
|
||||||
|
execution_order.append("after2")
|
||||||
|
return Div(id="after2")
|
||||||
|
|
||||||
|
main_command = Command('main', 'Main command', owner, main_callback)
|
||||||
|
before1_command = Command('before1', 'Before 1 command', owner, before1_callback)
|
||||||
|
before2_command = Command('before2', 'Before 2 command', owner, before2_callback)
|
||||||
|
after1_command = Command('after1', 'After 1 command', owner, after1_callback)
|
||||||
|
after2_command = Command('after2', 'After 2 command', owner, after2_callback)
|
||||||
|
|
||||||
|
owner.bind_command(main_command, before1_command, when="before")
|
||||||
|
owner.bind_command(main_command, before2_command, when="before")
|
||||||
|
owner.bind_command(main_command, after1_command, when="after")
|
||||||
|
owner.bind_command(main_command, after2_command, when="after")
|
||||||
|
|
||||||
|
res = main_command.execute()
|
||||||
|
|
||||||
|
assert execution_order == ["before1", "before2", "main", "after1", "after2"]
|
||||||
|
assert isinstance(res, list)
|
||||||
|
|
||||||
|
def test_main_callback_result_is_first_in_all_ret(self, owner):
|
||||||
|
"""Test that main callback result is always all_ret[0] for HTMX target."""
|
||||||
|
|
||||||
|
def main_callback():
|
||||||
|
return Div(id="main", cls="main-result")
|
||||||
|
|
||||||
|
def before_callback():
|
||||||
|
return Div(id="before")
|
||||||
|
|
||||||
|
def after_callback():
|
||||||
|
return Div(id="after")
|
||||||
|
|
||||||
|
main_command = Command('main', 'Main command', owner, main_callback)
|
||||||
|
before_command = Command('before', 'Before command', owner, before_callback)
|
||||||
|
after_command = Command('after', 'After command', owner, after_callback)
|
||||||
|
|
||||||
|
owner.bind_command(main_command, before_command, when="before")
|
||||||
|
owner.bind_command(main_command, after_command, when="after")
|
||||||
|
|
||||||
|
res = main_command.execute()
|
||||||
|
|
||||||
|
assert isinstance(res, list)
|
||||||
|
assert res[0].attrs["id"] == "main"
|
||||||
|
assert res[0].attrs.get("class") == "main-result"
|
||||||
|
|
||||||
|
def test_hx_swap_oob_is_applied_to_bound_commands_results(self, owner):
|
||||||
|
"""Test that hx-swap-oob is applied to bound commands results but not to main result."""
|
||||||
|
|
||||||
|
def main_callback():
|
||||||
|
return Div(id="main")
|
||||||
|
|
||||||
|
def before_callback():
|
||||||
|
return Div(id="before")
|
||||||
|
|
||||||
|
def after_callback():
|
||||||
|
return Div(id="after")
|
||||||
|
|
||||||
|
main_command = Command('main', 'Main command', owner, main_callback)
|
||||||
|
before_command = Command('before', 'Before command', owner, before_callback)
|
||||||
|
after_command = Command('after', 'After command', owner, after_callback)
|
||||||
|
|
||||||
|
owner.bind_command(main_command, before_command, when="before")
|
||||||
|
owner.bind_command(main_command, after_command, when="after")
|
||||||
|
|
||||||
|
res = main_command.execute()
|
||||||
|
|
||||||
|
assert isinstance(res, list)
|
||||||
|
assert len(res) == 3
|
||||||
|
# Main result should NOT have hx-swap-oob
|
||||||
|
assert "hx-swap-oob" not in res[0].attrs
|
||||||
|
# Bound commands results should have hx-swap-oob
|
||||||
|
assert res[1].attrs["hx-swap-oob"] == "true"
|
||||||
|
assert res[2].attrs["hx-swap-oob"] == "true"
|
||||||
|
|
||||||
|
def test_bound_commands_without_return_do_not_affect_main_result(self, owner):
|
||||||
|
"""Test that bound commands without return value do not affect main result."""
|
||||||
|
|
||||||
|
def main_callback():
|
||||||
|
return Div(id="main")
|
||||||
|
|
||||||
|
def before_callback():
|
||||||
|
# No return value
|
||||||
|
pass
|
||||||
|
|
||||||
|
def after_callback():
|
||||||
|
# No return value
|
||||||
|
pass
|
||||||
|
|
||||||
|
main_command = Command('main', 'Main command', owner, main_callback)
|
||||||
|
before_command = Command('before', 'Before command', owner, before_callback)
|
||||||
|
after_command = Command('after', 'After command', owner, after_callback)
|
||||||
|
|
||||||
|
owner.bind_command(main_command, before_command, when="before")
|
||||||
|
owner.bind_command(main_command, after_command, when="after")
|
||||||
|
|
||||||
|
res = main_command.execute()
|
||||||
|
|
||||||
|
# When bound commands return None, they are still included in the result list
|
||||||
|
# but the main callback result should still be first
|
||||||
|
assert isinstance(res, list)
|
||||||
|
assert res[0].attrs["id"] == "main"
|
||||||
|
|
||||||
|
def test_i_can_combine_bound_commands_with_observable_bindings(self, owner):
|
||||||
|
"""Test that bound commands work correctly with observable bindings."""
|
||||||
|
data = Data("initial")
|
||||||
|
execution_order = []
|
||||||
|
|
||||||
|
def on_data_change(old, new):
|
||||||
|
execution_order.append("observable")
|
||||||
|
return Div(id="observable", cls="data-changed")
|
||||||
|
|
||||||
|
def main_callback():
|
||||||
|
execution_order.append("main")
|
||||||
|
data.value = "modified"
|
||||||
|
return Div(id="main")
|
||||||
|
|
||||||
|
def before_callback():
|
||||||
|
execution_order.append("before")
|
||||||
|
return Div(id="before")
|
||||||
|
|
||||||
|
make_observable(data)
|
||||||
|
bind(data, "value", on_data_change)
|
||||||
|
|
||||||
|
main_command = Command('main', 'Main command', owner, main_callback).bind(data)
|
||||||
|
before_command = Command('before', 'Before command', owner, before_callback)
|
||||||
|
|
||||||
|
owner.bind_command(main_command, before_command, when="before")
|
||||||
|
|
||||||
|
res = main_command.execute()
|
||||||
|
|
||||||
|
# Execution order: before -> main -> observable change
|
||||||
|
assert execution_order == ["before", "main", "observable"]
|
||||||
|
assert isinstance(res, list)
|
||||||
|
|
||||||
|
|
||||||
class TestLambaCommand:
|
class TestLambaCommand:
|
||||||
|
|
||||||
@@ -209,3 +405,66 @@ class TestLambaCommand:
|
|||||||
def test_by_default_target_is_none(self):
|
def test_by_default_target_is_none(self):
|
||||||
command = LambdaCommand(None, lambda: "Hello World")
|
command = LambdaCommand(None, lambda: "Hello World")
|
||||||
assert command.get_htmx_params()["hx-swap"] == "none"
|
assert command.get_htmx_params()["hx-swap"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBoundCommand:
|
||||||
|
"""Tests for BoundCommand dataclass."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("when_param, expected_when", [
|
||||||
|
(None, "after"), # default
|
||||||
|
("before", "before"), # explicit before
|
||||||
|
("after", "after"), # explicit after
|
||||||
|
])
|
||||||
|
def test_i_can_create_bound_command(self, when_param, expected_when):
|
||||||
|
"""Test that BoundCommand can be created with different when values."""
|
||||||
|
command = Command('test', 'Command description', None, callback)
|
||||||
|
|
||||||
|
if when_param is None:
|
||||||
|
bound = BoundCommand(command=command)
|
||||||
|
else:
|
||||||
|
bound = BoundCommand(command=command, when=when_param)
|
||||||
|
|
||||||
|
assert bound.command is command
|
||||||
|
assert bound.when == expected_when
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandBindCommand:
|
||||||
|
"""Tests for binding commands to other commands with when parameter."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("when_param, expected_when", [
|
||||||
|
(None, "after"), # default
|
||||||
|
("before", "before"), # explicit before
|
||||||
|
("after", "after"), # explicit after
|
||||||
|
])
|
||||||
|
def test_i_can_bind_command_with_when(self, owner, when_param, expected_when):
|
||||||
|
"""Test that a command can be bound to another command with when parameter."""
|
||||||
|
main_command = Command('main', 'Main command', owner, callback)
|
||||||
|
bound_command = Command('bound', 'Bound command', owner, callback)
|
||||||
|
|
||||||
|
if when_param is None:
|
||||||
|
owner.bind_command(main_command, bound_command)
|
||||||
|
else:
|
||||||
|
owner.bind_command(main_command, bound_command, when=when_param)
|
||||||
|
|
||||||
|
bound_commands = owner.get_bound_commands('main')
|
||||||
|
|
||||||
|
assert len(bound_commands) == 1
|
||||||
|
assert bound_commands[0].command is bound_command
|
||||||
|
assert bound_commands[0].when == expected_when
|
||||||
|
|
||||||
|
def test_i_can_bind_multiple_commands_with_different_when(self, owner):
|
||||||
|
"""Test that multiple commands can be bound with different when values."""
|
||||||
|
main_command = Command('main', 'Main command', owner, callback)
|
||||||
|
before_command = Command('before', 'Before command', owner, callback)
|
||||||
|
after_command = Command('after', 'After command', owner, callback)
|
||||||
|
|
||||||
|
owner.bind_command(main_command, before_command, when="before")
|
||||||
|
owner.bind_command(main_command, after_command, when="after")
|
||||||
|
|
||||||
|
bound_commands = owner.get_bound_commands('main')
|
||||||
|
|
||||||
|
assert len(bound_commands) == 2
|
||||||
|
assert bound_commands[0].command is before_command
|
||||||
|
assert bound_commands[0].when == "before"
|
||||||
|
assert bound_commands[1].command is after_command
|
||||||
|
assert bound_commands[1].when == "after"
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ class TestErrorComparisonOutput:
|
|||||||
res = comparison_out.render()
|
res = comparison_out.render()
|
||||||
|
|
||||||
assert "\n" + res == '''
|
assert "\n" + res == '''
|
||||||
|
===== Actual ====== | ==== Expected =====
|
||||||
(div "attr1"="value1" | (div "attr1"="value1"
|
(div "attr1"="value1" | (div "attr1"="value1"
|
||||||
(p "id"="p_id") | (p "id"="p_id")
|
(p "id"="p_id") | (p "id"="p_id")
|
||||||
) | )'''
|
) | )'''
|
||||||
@@ -366,6 +367,7 @@ class TestErrorComparisonOutput:
|
|||||||
res = comparison_out.render()
|
res = comparison_out.render()
|
||||||
|
|
||||||
assert "\n" + res == '''
|
assert "\n" + res == '''
|
||||||
|
======= Actual ======== | ====== Expected =======
|
||||||
(div "id"="div_id" ... | (div "id"="div_id" ...
|
(div "id"="div_id" ... | (div "id"="div_id" ...
|
||||||
(span "class"="cls" ... | (span "class"="cls" ...
|
(span "class"="cls" ... | (span "class"="cls" ...
|
||||||
(div "attr1"="value1" | (div "attr1"="value1"
|
(div "attr1"="value1" | (div "attr1"="value1"
|
||||||
@@ -383,6 +385,7 @@ class TestErrorComparisonOutput:
|
|||||||
res = comparison_out.render()
|
res = comparison_out.render()
|
||||||
|
|
||||||
assert "\n" + res == '''
|
assert "\n" + res == '''
|
||||||
|
========= Actual ========= | ==== Expected =====
|
||||||
(div "attr2"="** MISSING **" | (div "attr2"="value1"
|
(div "attr2"="** MISSING **" | (div "attr2"="value1"
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^ |
|
^^^^^^^^^^^^^^^^^^^^^^^ |
|
||||||
(p "id"="p_id") | (p "id"="p_id")
|
(p "id"="p_id") | (p "id"="p_id")
|
||||||
@@ -399,6 +402,7 @@ class TestErrorComparisonOutput:
|
|||||||
res = comparison_out.render()
|
res = comparison_out.render()
|
||||||
|
|
||||||
assert "\n" + res == '''
|
assert "\n" + res == '''
|
||||||
|
===== Actual ====== | ==== Expected =====
|
||||||
(div "attr1"="value2" | (div "attr1"="value1"
|
(div "attr1"="value2" | (div "attr1"="value1"
|
||||||
^^^^^^^^^^^^^^^^ |
|
^^^^^^^^^^^^^^^^ |
|
||||||
(p "id"="p_id") | (p "id"="p_id")
|
(p "id"="p_id") | (p "id"="p_id")
|
||||||
@@ -415,6 +419,7 @@ class TestErrorComparisonOutput:
|
|||||||
res = comparison_out.render()
|
res = comparison_out.render()
|
||||||
|
|
||||||
assert "\n" + res == '''
|
assert "\n" + res == '''
|
||||||
|
===== Actual ====== | ==== Expected =====
|
||||||
(div "attr1"="value1" | (div "attr1"="value1"
|
(div "attr1"="value1" | (div "attr1"="value1"
|
||||||
(p "id"="p_id") | (span "id"="s_id")
|
(p "id"="p_id") | (span "id"="s_id")
|
||||||
^ ^^^^^^^^^^^ |
|
^ ^^^^^^^^^^^ |
|
||||||
@@ -432,6 +437,7 @@ class TestErrorComparisonOutput:
|
|||||||
assert "\n" + debug_output == """
|
assert "\n" + debug_output == """
|
||||||
Path : 'div'
|
Path : 'div'
|
||||||
Error : The condition 'Contains(value2)' is not satisfied.
|
Error : The condition 'Contains(value2)' is not satisfied.
|
||||||
|
====== Actual ====== | ========== Expected ==========
|
||||||
(div "attr1"="value1") | (div "attr1"="Contains(value2)")
|
(div "attr1"="value1") | (div "attr1"="Contains(value2)")
|
||||||
^^^^^^^^^^^^^^^^ |"""
|
^^^^^^^^^^^^^^^^ |"""
|
||||||
|
|
||||||
@@ -447,6 +453,7 @@ Error : The condition 'Contains(value2)' is not satisfied.
|
|||||||
res = comparison_out.render()
|
res = comparison_out.render()
|
||||||
|
|
||||||
assert "\n" + res == '''
|
assert "\n" + res == '''
|
||||||
|
============= Actual ============= | ============= Expected =============
|
||||||
(div "attr1"="123" "attr2"="value2") | (Dummy "attr1"="123" "attr2"="value2")
|
(div "attr1"="123" "attr2"="value2") | (Dummy "attr1"="123" "attr2"="value2")
|
||||||
^^^ |'''
|
^^^ |'''
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user