Skip to content

Implement Model Identity Keys #345

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Apr 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:
- cron: "0 0 * * *"

jobs:
coverage:
test-coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
Expand Down
36 changes: 18 additions & 18 deletions docs/source/core-concepts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,38 +66,32 @@ ever be removed from the model. Then you'll just need to call and await a
async with idom.Layout(ClickCount()) as layout:
patch = await layout.render()

The layout also handles the triggering of event handlers. Normally this is done
automatically by a :ref:`Dispatcher <Layout Dispatcher>`, but for now we'll do it manually.
We can use a trick to hard-code the ``event_handler_id`` so we can pass it, and a fake
event, to the layout's :meth:`~idom.core.layout.Layout.dispatch` method. Then we just
have to re-render the layout and see what changed:
The layout also handles the triggering of event handlers. Normally these are
automatically sent to a :ref:`Dispatcher <Layout Dispatcher>`, but for now we'll do it
manually. To do this we need to pass a fake event with its "target" (event handler
identifier), to the layout's :meth:`~idom.core.layout.Layout.dispatch` method, after
which we can re-render and see what changed:

.. testcode::

from idom.core.layout import LayoutEvent
from idom.testing import StaticEventHandler


event_handler_id = "on-click"

static_handler = StaticEventHandler()

@idom.component
def ClickCount():
count, set_count = idom.hooks.use_state(0)

@idom.event(target_id=event_handler_id) # <-- trick to hard code event handler ID
def on_click(event):
set_count(count + 1)

return idom.html.button(
{"onClick": on_click},
[f"Click count: {count}"],
)
# we do this in order to capture the event handler's target ID
handler = static_handler.use(lambda event: set_count(count + 1))

return idom.html.button({"onClick": handler}, [f"Click count: {count}"])

async with idom.Layout(ClickCount()) as layout:
patch_1 = await layout.render()

fake_event = LayoutEvent(event_handler_id, [{}])
fake_event = LayoutEvent(target=static_handler.target, data=[{}])
await layout.dispatch(fake_event)
patch_2 = await layout.render()

Expand All @@ -107,6 +101,12 @@ have to re-render the layout and see what changed:

assert count_did_increment

.. note::

Don't worry about the format of the layout event's ``target``. Its an internal
detail of the layout's implementation that is neither neccessary to understanding
how things work, nor is it part of the interface clients should rely on.


Layout Dispatcher
-----------------
Expand Down Expand Up @@ -138,7 +138,7 @@ callback that's called by the dispatcher to events it should execute.


async def recv():
event = LayoutEvent(event_handler_id, [{}])
event = LayoutEvent(target=static_handler.target, data=[{}])

# We need this so we don't flood the render loop with events.
# In practice this is never an issue since events won't arrive
Expand Down
1 change: 1 addition & 0 deletions requirements/check-style.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
black
flake8
flake8-print
pep8-naming
flake8-idom-hooks >=0.4.0
isort >=5.7.0
26 changes: 26 additions & 0 deletions src/idom/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,29 @@
Absolute URL
``ABSOLUTE_PATH/my-module.js``
"""

IDOM_FEATURE_INDEX_AS_DEFAULT_KEY = _option.Option(
"IDOM_FEATURE_INDEX_AS_DEFAULT_KEY",
default=False,
mutable=False,
validator=lambda x: bool(int(x)),
)
"""A feature flag for using the index of a sibling element as its default key

In a future release this flag's default value will be set to true, and after that, this
flag willbe removed entirely and the indices will always be the default key.

For more information on changes to this feature flag see: https://github.com/idom-team/idom/issues/351
"""

if not IDOM_FEATURE_INDEX_AS_DEFAULT_KEY.get(): # pragma: no cover
from warnings import warn

warn(
(
"In a future release 'IDOM_FEATURE_INDEX_AS_DEFAULT_KEY' will be turned on "
"by default. For more information on changes to this feature flag, see: "
"https://github.com/idom-team/idom/issues/351"
),
UserWarning,
)
50 changes: 29 additions & 21 deletions src/idom/core/component.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
from __future__ import annotations

import abc
import inspect
from functools import wraps
from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple, Union

from typing import Any, Callable, Dict, Optional, Tuple, Union

if TYPE_CHECKING: # pragma: no cover
from .vdom import VdomDict # noqa
from .utils import hex_id
from .vdom import VdomDict


ComponentConstructor = Callable[..., "AbstractComponent"]
ComponentRenderFunction = Callable[..., Union["AbstractComponent", "VdomDict"]]
ComponentRenderFunction = Callable[..., Union["AbstractComponent", VdomDict]]


def component(function: ComponentRenderFunction) -> Callable[..., "Component"]:
Expand All @@ -21,52 +22,59 @@ def component(function: ComponentRenderFunction) -> Callable[..., "Component"]:
"""

@wraps(function)
def constructor(*args: Any, **kwargs: Any) -> Component:
return Component(function, args, kwargs)
def constructor(*args: Any, key: Optional[Any] = None, **kwargs: Any) -> Component:
return Component(function, key, args, kwargs)

return constructor


class AbstractComponent(abc.ABC):

__slots__ = [] if hasattr(abc.ABC, "__weakref__") else ["__weakref__"]
__slots__ = ["key"]
if not hasattr(abc.ABC, "__weakref__"):
__slots__.append("__weakref__") # pragma: no cover

key: Optional[Any]

@abc.abstractmethod
def render(self) -> "VdomDict":
def render(self) -> VdomDict:
"""Render the component's :ref:`VDOM <VDOM Mimetype>` model."""


class Component(AbstractComponent):
"""An object for rending component models."""

__slots__ = (
"_function",
"_args",
"_kwargs",
)
__slots__ = "_func", "_args", "_kwargs"

def __init__(
self,
function: ComponentRenderFunction,
key: Optional[Any],
args: Tuple[Any, ...],
kwargs: Dict[str, Any],
) -> None:
self._function = function
self.key = key
self._func = function
self._args = args
self._kwargs = kwargs
if key is not None:
kwargs["key"] = key

def render(self) -> Any:
return self._function(*self._args, **self._kwargs)
def render(self) -> VdomDict:
model = self._func(*self._args, **self._kwargs)
if isinstance(model, AbstractComponent):
model = {"tagName": "div", "children": [model]}
return model

def __repr__(self) -> str:
sig = inspect.signature(self._function)
sig = inspect.signature(self._func)
try:
args = sig.bind(*self._args, **self._kwargs).arguments
except TypeError:
return f"{self._function.__name__}(...)"
return f"{self._func.__name__}(...)"
else:
items = ", ".join(f"{k}={v!r}" for k, v in args.items())
if items:
return f"{self._function.__name__}({hex(id(self))}, {items})"
return f"{self._func.__name__}({hex_id(self)}, {items})"
else:
return f"{self._function.__name__}({hex(id(self))})"
return f"{self._func.__name__}({hex_id(self)})"
67 changes: 18 additions & 49 deletions src/idom/core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,15 @@
)

from anyio import create_task_group
from mypy_extensions import TypedDict


EventsMapping = Union[Dict[str, Union["Callable[..., Any]", "EventHandler"]], "Events"]


class EventTarget(TypedDict):
target: str
preventDefault: bool # noqa
stopPropagation: bool # noqa


def event(
function: Optional[Callable[..., Any]] = None,
stop_propagation: bool = False,
prevent_default: bool = False,
target_id: Optional[str] = None,
) -> Union["EventHandler", Callable[[Callable[..., Any]], "EventHandler"]]:
"""Create an event handler function with extra functionality.

Expand All @@ -49,12 +41,8 @@ def event(
Block the event from propagating further up the DOM.
prevent_default:
Stops the default actional associate with the event from taking place.
target_id:
A unique ID used to locate this handler in the resulting VDOM. This is
automatically generated by default and is typically not set manually
except in testing.
"""
handler = EventHandler(stop_propagation, prevent_default, target_id=target_id)
handler = EventHandler(stop_propagation, prevent_default)
if function is not None:
handler.add(function)
return handler
Expand Down Expand Up @@ -85,7 +73,7 @@ def on(
stop_propagation:
Block the event from propagating further up the DOM.
prevent_default:
Stops the default actional associate with the event from taking place.
Stops the default action associate with the event from taking place.

Returns:
A decorator which accepts an event handler function as its first argument.
Expand Down Expand Up @@ -142,53 +130,40 @@ def __repr__(self) -> str: # pragma: no cover
class EventHandler:
"""An object which defines an event handler.

Get a serialized reference to the handler via :meth:`Handler.serialize`.

The event handler object acts like a coroutine when called.

Parameters:
event_name:
The camel case name of the event.
target_id:
A unique identifier for the event handler. This is generally used if
an element has more than on event handler for the same event type. If
no ID is provided one will be generated automatically.
stop_propagation:
Block the event from propagating further up the DOM.
prevent_default:
Stops the default action associate with the event from taking place.
"""

__slots__ = (
"__weakref__",
"_coro_handlers",
"_func_handlers",
"_target_id",
"_prevent_default",
"_stop_propogation",
"prevent_default",
"stop_propagation",
)

def __init__(
self,
stop_propagation: bool = False,
prevent_default: bool = False,
target_id: Optional[str] = None,
) -> None:
self.stop_propagation = stop_propagation
self.prevent_default = prevent_default
self._coro_handlers: List[Callable[..., Coroutine[Any, Any, Any]]] = []
self._func_handlers: List[Callable[..., Any]] = []
self._target_id = target_id or str(id(self))
self._stop_propogation = stop_propagation
self._prevent_default = prevent_default

@property
def id(self) -> str:
"""ID of the event handler."""
return self._target_id

def add(self, function: Callable[..., Any]) -> "EventHandler":
"""Add a callback to the event handler.
"""Add a callback function or coroutine to the event handler.

Parameters:
function:
The event handler function. Its parameters may indicate event attributes
which should be sent back from the fronend unless otherwise specified by
the ``properties`` parameter.
The event handler function accepting parameters sent by the client.
Typically this is a single ``event`` parameter that is a dictionary.
"""
if asyncio.iscoroutinefunction(function):
self._coro_handlers.append(function)
Expand All @@ -197,7 +172,7 @@ def add(self, function: Callable[..., Any]) -> "EventHandler":
return self

def remove(self, function: Callable[..., Any]) -> None:
"""Remove the function from the event handler.
"""Remove the given function or coroutine from this event handler.

Raises:
ValueError: if not found
Expand All @@ -207,13 +182,10 @@ def remove(self, function: Callable[..., Any]) -> None:
else:
self._func_handlers.remove(function)

def serialize(self) -> EventTarget:
"""Serialize the event handler."""
return {
"target": self._target_id,
"preventDefault": self._prevent_default,
"stopPropagation": self._stop_propogation,
}
def clear(self) -> None:
"""Remove all functions and coroutines from this event handler"""
self._coro_handlers.clear()
self._func_handlers.clear()

async def __call__(self, data: List[Any]) -> Any:
"""Trigger all callbacks in the event handler."""
Expand All @@ -229,6 +201,3 @@ def __contains__(self, function: Any) -> bool:
return function in self._coro_handlers
else:
return function in self._func_handlers

def __repr__(self) -> str: # pragma: no cover
return f"{type(self).__name__}({self.serialize()})"
Loading