Add @when decorator (#1428)

* Add new _event_handling.py file with @when decorator

* @when decorate is in pyscript package namespace/_all__

* Write tests in new test_event_handling.py

* Add docs for @when decorator

------------

Co-authored-by: Mariana Meireles <marian.meireles@gmail.com>
This commit is contained in:
Jeff Glass
2023-05-01 09:51:49 -05:00
committed by GitHub
parent 0a4e36ae09
commit 3a66be585f
7 changed files with 338 additions and 0 deletions

View File

@@ -7,6 +7,7 @@
Features Features
-------- --------
- Added the `@when` decorator for attaching Python functions as event handlers
- The `py-mount` attribute on HTML elements has been deprecated, and will be removed in a future release. - The `py-mount` attribute on HTML elements has been deprecated, and will be removed in a future release.

View File

@@ -0,0 +1,51 @@
# `@when`
`@when(event_type:str = None, selector:str = None)`
The `@when` decorator attaches the decorated function or Callable as an event handler for selected objects on the page. That is, when the named event is emitted by the selected DOM elements, the decorated Python function will be called.
If the decorated function takes a single (non-self) argument, it will be passed the [Event object](https://developer.mozilla.org/en-US/docs/Web/API/Event) corresponding to the triggered event. If the function takes no (non-self) argument, it will be called with no arguments.
## Parameters
`event_type` - A string representing the event type to match against. This can be any of the [https://developer.mozilla.org/en-US/docs/Web/Events#event_listing](https://developer.mozilla.org/en-US/docs/Web/Events) that HTML elements may emit, as appropriate to their element type.
`selector` = A string containing one or more [CSS selectors](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Selectors). The selected DOM elements will have the decorated function attacehed as an event handler.
## Examples:
The following example prints "Hello, world!" whenever the button is clicked. It demonstrates using the `@when` decorator on a Callable which takes no arguments:
```html
<button id="my_btn">Click Me to Say Hi</button>
<py-script>
from pyscript import when
@when("click", selector="#my_btn")
def say_hello():
print(f"Hello, world!")
</py-script>
```
The following example includes three buttons - when any of the buttons is clicked, that button turns green, and the remaining two buttons turn red. This demonstrates using the `@when` decorator on a Callable which takes one argument, which is then passed the Event object from the associated event. When combined with the ability to look at other elements in on the page, this is quite a powerful feature.
```html
<div id="container">
<button>First</button>
<button>Second</button>
<button>Third</button>
</div>
<py-script>
from pyscript import when
import js
@when("click", selector="#container button")
def highlight(evt):
#Set the clicked button's background to green
evt.target.style.backgroundColor = 'green'
#Set the background of all buttons to red
other_buttons = (button for button in js.document.querySelectorAll('button') if button != evt.target)
for button in other_buttons:
button.style.backgroundColor = 'red'
</py-script>
```

View File

@@ -1,5 +1,6 @@
from _pyscript_js import showWarning from _pyscript_js import showWarning
from ._event_handling import when
from ._event_loop import LOOP as loop from ._event_loop import LOOP as loop
from ._event_loop import run_until_complete from ._event_loop import run_until_complete
from ._html import ( from ._html import (
@@ -51,4 +52,5 @@ __all__ = [
"__version__", "__version__",
"version_info", "version_info",
"showWarning", "showWarning",
"when",
] ]

View File

@@ -0,0 +1,29 @@
import inspect
import js
from pyodide.ffi.wrappers import add_event_listener
def when(event_type=None, selector=None):
"""
Decorates a function and passes py-* events to the decorated function
The events might or not be an argument of the decorated function
"""
def decorator(func):
elements = js.document.querySelectorAll(selector)
sig = inspect.signature(func)
# Function doesn't receive events
if not sig.parameters:
def wrapper(*args, **kwargs):
func()
for el in elements:
add_event_listener(el, event_type, wrapper)
else:
for el in elements:
add_event_listener(el, event_type, func)
return func
return decorator

View File

@@ -316,6 +316,7 @@ class TestBasic(PyScriptTest):
) )
btn = self.page.wait_for_selector("button") btn = self.page.wait_for_selector("button")
btn.click() btn.click()
self.wait_for_console("hello world!")
assert self.console.log.lines[-1] == "hello world!" assert self.console.log.lines[-1] == "hello world!"
assert self.console.error.lines == [] assert self.console.error.lines == []

View File

@@ -0,0 +1,194 @@
from .support import PyScriptTest, skip_worker
class TestEventHandler(PyScriptTest):
@skip_worker(reason="FIXME: js.document (@when decorator)")
def test_when_decorator_with_event(self):
"""When the decorated function takes a single parameter,
it should be passed the event object
"""
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<py-script>
from pyscript import when
@when("click", selector="#foo_id")
def foo(evt):
print(f"I've clicked {evt.target} with id {evt.target.id}")
</py-script>
"""
)
self.page.locator("text=foo_button").click()
console_text = self.console.all.lines
self.wait_for_console("I've clicked [object HTMLButtonElement] with id foo_id")
assert "I've clicked [object HTMLButtonElement] with id foo_id" in console_text
self.assert_no_banners()
@skip_worker(reason="FIXME: js.document (@when decorator)")
def test_when_decorator_without_event(self):
"""When the decorated function takes no parameters (not including 'self'),
it should be called without the event object
"""
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<py-script>
from pyscript import when
@when("click", selector="#foo_id")
def foo():
print("The button was clicked")
</py-script>
"""
)
self.page.locator("text=foo_button").click()
self.wait_for_console("The button was clicked")
assert "The button was clicked" in self.console.log.lines
self.assert_no_banners()
@skip_worker(reason="FIXME: js.document (@when decorator)")
def test_multiple_when_decorators_with_event(self):
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<button id="bar_id">bar_button</button>
<py-script>
from pyscript import when
@when("click", selector="#foo_id")
def foo(evt):
print(f"I've clicked {evt.target} with id {evt.target.id}")
@when("click", selector="#bar_id")
def foo(evt):
print(f"I've clicked {evt.target} with id {evt.target.id}")
</py-script>
"""
)
self.page.locator("text=foo_button").click()
console_text = self.console.all.lines
self.wait_for_console("I've clicked [object HTMLButtonElement] with id foo_id")
assert "I've clicked [object HTMLButtonElement] with id foo_id" in console_text
self.page.locator("text=bar_button").click()
console_text = self.console.all.lines
self.wait_for_console("I've clicked [object HTMLButtonElement] with id bar_id")
assert "I've clicked [object HTMLButtonElement] with id bar_id" in console_text
self.assert_no_banners()
@skip_worker(reason="FIXME: js.document (@when decorator)")
def test_two_when_decorators(self):
"""When decorating a function twice, both should function"""
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<button class="bar_class">bar_button</button>
<py-script>
from pyscript import when
@when("click", selector="#foo_id")
@when("mouseover", selector=".bar_class")
def foo(evt):
print(f"An event of type {evt.type} happened")
</py-script>
"""
)
self.page.locator("text=bar_button").hover()
self.page.locator("text=foo_button").click()
self.wait_for_console("An event of type click happened")
assert "An event of type mouseover happened" in self.console.log.lines
assert "An event of type click happened" in self.console.log.lines
self.assert_no_banners()
@skip_worker(reason="FIXME: js.document (@when decorator)")
def test_two_when_decorators_same_element(self):
"""When decorating a function twice *on the same DOM element*, both should function"""
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<py-script>
from pyscript import when
@when("click", selector="#foo_id")
@when("mouseover", selector="#foo_id")
def foo(evt):
print(f"An event of type {evt.type} happened")
</py-script>
"""
)
self.page.locator("text=foo_button").hover()
self.page.locator("text=foo_button").click()
self.wait_for_console("An event of type click happened")
assert "An event of type mouseover happened" in self.console.log.lines
assert "An event of type click happened" in self.console.log.lines
self.assert_no_banners()
@skip_worker(reason="FIXME: js.document (@when decorator)")
def test_when_decorator_multiple_elements(self):
"""The @when decorator's selector should successfully select multiple
DOM elements
"""
self.pyscript_run(
"""
<button class="bar_class">button1</button>
<button class="bar_class">button2</button>
<py-script>
from pyscript import when
@when("click", selector=".bar_class")
def foo(evt):
print(f"{evt.target.innerText} was clicked")
</py-script>
"""
)
self.page.locator("text=button1").click()
self.page.locator("text=button2").click()
self.wait_for_console("button2 was clicked")
assert "button1 was clicked" in self.console.log.lines
assert "button2 was clicked" in self.console.log.lines
self.assert_no_banners()
@skip_worker(reason="FIXME: js.document (@when decorator)")
def test_when_decorator_duplicate_selectors(self):
""" """
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<py-script>
from pyscript import when
@when("click", selector="#foo_id")
@when("click", selector="#foo_id")
def foo(evt):
print(f"I've clicked {evt.target} with id {evt.target.id}")
</py-script>
"""
)
self.page.locator("text=foo_button").click()
console_text = self.console.all.lines
self.wait_for_console("I've clicked [object HTMLButtonElement] with id foo_id")
assert (
console_text.count("I've clicked [object HTMLButtonElement] with id foo_id")
== 2
)
self.assert_no_banners()
@skip_worker(reason="FIXME: js.document (@when decorator)")
def test_when_decorator_invalid_selector(self):
"""When the selector parameter of @when is invalid, it should show an error"""
self.pyscript_run(
"""
<button id="foo_id">foo_button</button>
<py-script>
from pyscript import when
@when("click", selector="#.bad")
def foo(evt):
...
</py-script>
"""
)
self.page.locator("text=foo_button").click()
msg = "Failed to execute 'querySelectorAll' on 'Document': '#.bad' is not a valid selector."
error = self.page.wait_for_selector(".py-error")
banner_text = error.inner_text()
if msg not in banner_text:
raise AssertionError(
f"Expected message '{msg}' does not "
f"match banner text '{banner_text}'"
)
assert any(msg in line for line in self.console.error.lines)

View File

@@ -209,3 +209,63 @@ class TestDocsSnippets(PyScriptTest):
py_terminal = self.page.wait_for_selector("py-terminal") py_terminal = self.page.wait_for_selector("py-terminal")
assert "0\n1\n2\n" in py_terminal.inner_text() assert "0\n1\n2\n" in py_terminal.inner_text()
@skip_worker(reason="FIXME: js.document (@when decorator)")
def test_reference_when_simple(self):
self.pyscript_run(
"""
<button id="my_btn">Click Me to Say Hi</button>
<py-script>
from pyscript import when
@when("click", selector="#my_btn")
def say_hello():
print(f"Hello, world!")
</py-script>
"""
)
self.page.get_by_text("Click Me to Say Hi").click()
self.wait_for_console("Hello, world!")
assert ("Hello, world!") in self.console.log.lines
@skip_worker(reason="FIXME: js.document (@when decorator)")
def test_reference_when_complex(self):
self.pyscript_run(
"""
<div id="container">
<button>First</button>
<button>Second</button>
<button>Third</button>
</div>
<py-script>
from pyscript import when
import js
@when("click", selector="#container button")
def highlight(evt):
#Set the clicked button's background to green
evt.target.style.backgroundColor = 'green'
#Set the background of all buttons to red
other_buttons = (button for button in js.document.querySelectorAll('button') if button != evt.target)
for button in other_buttons:
button.style.backgroundColor = 'red'
print("set") # Test Only
</py-script>
"""
)
def getBackgroundColor(locator):
return locator.evaluate(
"(element) => getComputedStyle(element).getPropertyValue('background-color')"
)
first_button = self.page.get_by_text("First")
assert getBackgroundColor(first_button) == "rgb(239, 239, 239)"
first_button.click()
self.wait_for_console("set")
assert getBackgroundColor(first_button) == "rgb(0, 128, 0)"
assert getBackgroundColor(self.page.get_by_text("Second")) == "rgb(255, 0, 0)"
assert getBackgroundColor(self.page.get_by_text("Third")) == "rgb(255, 0, 0)"