diff --git a/docs/source/adding-interactivity/_examples/print_chat_message.py b/docs/source/adding-interactivity/_examples/print_chat_message.py index ae81fbebd..35fbc23fb 100644 --- a/docs/source/adding-interactivity/_examples/print_chat_message.py +++ b/docs/source/adding-interactivity/_examples/print_chat_message.py @@ -22,7 +22,7 @@ async def handle_submit(event): html.select( { "value": recipient, - "onChange": lambda event: set_recipient(event["value"]), + "onChange": lambda event: set_recipient(event["target"]["value"]), }, html.option({"value": "Alice"}, "Alice"), html.option({"value": "Bob"}, "Bob"), @@ -33,7 +33,7 @@ async def handle_submit(event): "type": "text", "placeholder": "Your message...", "value": message, - "onChange": lambda event: set_message(event["value"]), + "onChange": lambda event: set_message(event["target"]["value"]), } ), html.button({"type": "submit"}, "Send"), diff --git a/docs/source/adding-interactivity/_examples/send_message.py b/docs/source/adding-interactivity/_examples/send_message.py index 61c938797..0ceaf8850 100644 --- a/docs/source/adding-interactivity/_examples/send_message.py +++ b/docs/source/adding-interactivity/_examples/send_message.py @@ -25,7 +25,7 @@ def handle_submit(event): { "placeholder": "Your message here...", "value": message, - "onChange": lambda event: set_message(event["value"]), + "onChange": lambda event: set_message(event["target"]["value"]), } ), html.button({"type": "submit"}, "Send"), diff --git a/docs/source/reference-material/_examples/matplotlib_plot.py b/docs/source/reference-material/_examples/matplotlib_plot.py index ee255a19a..94ab3af4a 100644 --- a/docs/source/reference-material/_examples/matplotlib_plot.py +++ b/docs/source/reference-material/_examples/matplotlib_plot.py @@ -25,7 +25,7 @@ def ExpandableNumberInputs(values, set_values): for i in range(len(values)): def set_value_at_index(event, index=i): - new_value = float(event["value"] or 0) + new_value = float(event["target"]["value"] or 0) set_values(values[:index] + [new_value] + values[index + 1 :]) inputs.append(poly_coef_input(i + 1, set_value_at_index)) diff --git a/docs/source/reference-material/_examples/todo.py b/docs/source/reference-material/_examples/todo.py index 8368d05ec..7b1f6f675 100644 --- a/docs/source/reference-material/_examples/todo.py +++ b/docs/source/reference-material/_examples/todo.py @@ -7,7 +7,7 @@ def Todo(): async def add_new_task(event): if event["key"] == "Enter": - set_items(items + [event["value"]]) + set_items(items + [event["target"]["value"]]) tasks = [] diff --git a/src/client/package-lock.json b/src/client/package-lock.json index 38b74236a..2742e2bbb 100644 --- a/src/client/package-lock.json +++ b/src/client/package-lock.json @@ -2657,6 +2657,7 @@ }, "devDependencies": { "jsdom": "16.3.0", + "lodash": "^4.17.21", "prettier": "^2.5.1", "uvu": "^0.5.1" }, @@ -3452,6 +3453,7 @@ "fast-json-patch": "^3.0.0-1", "htm": "^3.0.3", "jsdom": "16.3.0", + "lodash": "^4.17.21", "prettier": "^2.5.1", "uvu": "^0.5.1" } diff --git a/src/client/packages/idom-client-react/package.json b/src/client/packages/idom-client-react/package.json index 38d4ad663..5474ee3a8 100644 --- a/src/client/packages/idom-client-react/package.json +++ b/src/client/packages/idom-client-react/package.json @@ -7,6 +7,7 @@ "description": "A client for IDOM implemented in React", "devDependencies": { "jsdom": "16.3.0", + "lodash": "^4.17.21", "prettier": "^2.5.1", "uvu": "^0.5.1" }, @@ -25,8 +26,8 @@ "url": "https://github.com/idom-team/idom" }, "scripts": { - "format": "prettier --write ./src", - "check-format": "prettier --check ./src", + "format": "prettier --write ./src ./tests", + "check-format": "prettier --check ./src ./tests", "test": "uvu tests" }, "type": "module", diff --git a/src/client/packages/idom-client-react/src/event-to-object.js b/src/client/packages/idom-client-react/src/event-to-object.js index f4d48dd85..1e81a70b7 100644 --- a/src/client/packages/idom-client-react/src/event-to-object.js +++ b/src/client/packages/idom-client-react/src/event-to-object.js @@ -5,27 +5,40 @@ export function serializeEvent(event) { Object.assign(data, eventTransforms[event.type](event)); } - const target = event.target; - if (target.tagName in targetTransforms) { - targetTransforms[target.tagName].forEach((trans) => - Object.assign(data, trans(target)) - ); - } + data.target = serializeDomElement(event.target); + data.currentTarget = + event.target === event.currentTarget + ? data.target + : serializeDomElement(event.currentTarget); + data.relatedTarget = serializeDomElement(event.relatedTarget); return data; } -const targetTransformCategories = { - hasValue: (target) => ({ - value: target.value, +function serializeDomElement(element) { + let elementData = null; + if (element) { + elementData = defaultElementTransform(element); + if (element.tagName in elementTransforms) { + elementTransforms[element.tagName].forEach((trans) => + Object.assign(elementData, trans(element)) + ); + } + } + return elementData; +} + +const elementTransformCategories = { + hasValue: (element) => ({ + value: element.value, }), - hasCurrentTime: (target) => ({ - currentTime: target.currentTime, + hasCurrentTime: (element) => ({ + currentTime: element.currentTime, }), - hasFiles: (target) => { - if (target?.type === "file") { + hasFiles: (element) => { + if (element?.type === "file") { return { - files: Array.from(target.files).map((file) => ({ + files: Array.from(element.files).map((file) => ({ lastModified: file.lastModified, name: file.name, size: file.size, @@ -38,7 +51,11 @@ const targetTransformCategories = { }, }; -const targetTagCategories = { +function defaultElementTransform(element) { + return { boundingClientRect: element.getBoundingClientRect() }; +} + +const elementTagCategories = { hasValue: [ "BUTTON", "INPUT", @@ -54,12 +71,13 @@ const targetTagCategories = { hasFiles: ["INPUT"], }; -const targetTransforms = {}; +const elementTransforms = {}; -Object.keys(targetTagCategories).forEach((category) => { - targetTagCategories[category].forEach((type) => { - const transforms = targetTransforms[type] || (targetTransforms[type] = []); - transforms.push(targetTransformCategories[category]); +Object.keys(elementTagCategories).forEach((category) => { + elementTagCategories[category].forEach((type) => { + const transforms = + elementTransforms[type] || (elementTransforms[type] = []); + transforms.push(elementTransformCategories[category]); }); }); diff --git a/src/client/packages/idom-client-react/tests/event-to-object.test.js b/src/client/packages/idom-client-react/tests/event-to-object.test.js index b8e49122e..50f1da245 100644 --- a/src/client/packages/idom-client-react/tests/event-to-object.test.js +++ b/src/client/packages/idom-client-react/tests/event-to-object.test.js @@ -1,8 +1,43 @@ import { test } from "uvu"; +import lodash from "lodash"; import * as assert from "uvu/assert"; import { serializeEvent } from "../src/event-to-object.js"; import "./tooling/setup.js"; +function assertEqualSerializedEventData(eventData, expectedSerializedData) { + const mockBoundingRect = { + left: 0, + top: 0, + right: 0, + bottom: 0, + x: 0, + y: 0, + width: 0, + }; + + const mockElement = { + tagName: null, + getBoundingClientRect: () => mockBoundingRect, + }; + + const commonEventData = { + target: mockElement, + currentTarget: mockElement, + relatedTarget: mockElement, + }; + + const commonSerializedEventData = { + target: { boundingClientRect: mockBoundingRect }, + currentTarget: { boundingClientRect: mockBoundingRect }, + relatedTarget: { boundingClientRect: mockBoundingRect }, + }; + + assert.equal( + serializeEvent(lodash.merge({}, commonEventData, eventData)), + lodash.merge({}, commonSerializedEventData, expectedSerializedData) + ); +} + const allTargetData = { files: [ { @@ -21,33 +56,38 @@ const allTargetData = { { case: "adds 'files' and 'value' attributes for INPUT if type=file", tagName: "INPUT", - otherAttrs: { type: "file" }, + addTargetAttrs: { type: "file" }, output: { - files: allTargetData.files, - value: allTargetData.value, + target: { + files: allTargetData.files, + value: allTargetData.value, + }, }, }, ...["BUTTON", "INPUT", "OPTION", "LI", "METER", "PROGRESS", "PARAM"].map( (tagName) => ({ case: `adds 'value' attribute for ${tagName} element`, tagName, - output: { value: allTargetData.value }, + output: { target: { value: allTargetData.value } }, }) ), ...["AUDIO", "VIDEO"].map((tagName) => ({ case: `adds 'currentTime' attribute for ${tagName} element`, tagName, - output: { currentTime: allTargetData.currentTime }, + output: { target: { currentTime: allTargetData.currentTime } }, })), ].forEach((expectation) => { test(`serializeEvent() ${expectation.case}`, () => { const eventData = { - target: { ...allTargetData, tagName: expectation.tagName }, + target: { + ...allTargetData, + tagName: expectation.tagName, + }, }; - if (expectation.otherAttrs) { - Object.assign(eventData.target, expectation.otherAttrs); + if (expectation.addTargetAttrs) { + Object.assign(eventData.target, expectation.addTargetAttrs); } - assert.equal(serializeEvent(eventData), expectation.output); + assertEqualSerializedEventData(eventData, expectation.output); }); }); @@ -247,8 +287,8 @@ const allEventData = { }, ].forEach((expectation) => { test(`serializeEvent() adds ${expectation.case} attributes`, () => { - assert.equal( - serializeEvent({ ...allEventData, type: expectation.eventType }), + assertEqualSerializedEventData( + { ...allEventData, type: expectation.eventType }, expectation.output ); }); @@ -267,9 +307,12 @@ test("serializeEvent() adds text of current selection", () => { const start = document.getElementById("start"); const end = document.getElementById("end"); window.getSelection().setBaseAndExtent(start, 0, end, 0); - assert.equal(serializeEvent({ ...allEventData, type: "select" }), { - selectedText: "START\nMIDDLE\n", - }); + assertEqualSerializedEventData( + { ...allEventData, type: "select" }, + { + selectedText: "START\nMIDDLE\n", + } + ); }); test.run(); diff --git a/src/idom/core/_event_proxy.py b/src/idom/core/_event_proxy.py new file mode 100644 index 000000000..5c47aa57f --- /dev/null +++ b/src/idom/core/_event_proxy.py @@ -0,0 +1,38 @@ +from typing import Any, Dict, Sequence +from warnings import warn + + +def _wrap_in_warning_event_proxies(values: Sequence[Any]) -> Sequence[Any]: + return [_EventProxy(x) if isinstance(x, dict) else x for x in values] + + +class _EventProxy(Dict[Any, Any]): + def __getitem__(self, key: Any) -> Any: # pragma: no cover + try: + return super().__getitem__(key) + except KeyError: + target = self.get("target") + if isinstance(target, dict) and key in target: + warn( + f"The event key event[{key!r}] has been moved event['target'][{key!r}", + DeprecationWarning, + stacklevel=2, + ) + return target[key] + else: + raise + + def get(self, key: Any, default: Any = None) -> Any: # pragma: no cover + try: + return super().__getitem__(key) + except KeyError: + target = self.get("target") + if isinstance(target, dict) and key in target: + warn( + f"The event key event[{key!r}] has been moved event['target'][{key!r}", + DeprecationWarning, + stacklevel=2, + ) + return target[key] + else: + return default diff --git a/src/idom/core/events.py b/src/idom/core/events.py index 58fe7f2d5..9afb84e9f 100644 --- a/src/idom/core/events.py +++ b/src/idom/core/events.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from typing import Any, Callable, List, Optional, Sequence, overload +from typing import Any, Callable, Optional, Sequence, overload from anyio import create_task_group from typing_extensions import Literal @@ -145,18 +145,18 @@ def to_event_handler_function( if positional_args: if asyncio.iscoroutinefunction(function): - async def wrapper(data: List[Any]) -> None: + async def wrapper(data: Sequence[Any]) -> None: await function(*data) else: - async def wrapper(data: List[Any]) -> None: + async def wrapper(data: Sequence[Any]) -> None: function(*data) return wrapper elif not asyncio.iscoroutinefunction(function): - async def wrapper(data: List[Any]) -> None: + async def wrapper(data: Sequence[Any]) -> None: function(data) return wrapper @@ -212,7 +212,7 @@ def merge_event_handler_funcs( elif len(functions) == 1: return functions[0] - async def await_all_event_handlers(data: List[Any]) -> None: + async def await_all_event_handlers(data: Sequence[Any]) -> None: async with create_task_group() as group: for func in functions: group.start_soon(func, data) diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index 19bbe6fba..64f31ac6e 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -29,6 +29,7 @@ ) from idom.utils import Ref +from ._event_proxy import _wrap_in_warning_event_proxies from .hooks import LifeCycleHook from .proto import ComponentType, EventHandlerDict, VdomJson from .vdom import validate_vdom_json @@ -119,7 +120,7 @@ async def deliver(self, event: LayoutEvent) -> None: if handler is not None: try: - await handler.function(event.data) + await handler.function(_wrap_in_warning_event_proxies(event.data)) except Exception: logger.exception(f"Failed to execute event handler {handler}") else: diff --git a/src/idom/core/proto.py b/src/idom/core/proto.py index a572e3344..ed5d01369 100644 --- a/src/idom/core/proto.py +++ b/src/idom/core/proto.py @@ -138,7 +138,7 @@ class _JsonImportSource(TypedDict): class EventHandlerFunc(Protocol): """A coroutine which can handle event data""" - async def __call__(self, data: List[Any]) -> None: + async def __call__(self, data: Sequence[Any]) -> None: ... diff --git a/src/idom/widgets.py b/src/idom/widgets.py index cbd3656c5..741f744b5 100644 --- a/src/idom/widgets.py +++ b/src/idom/widgets.py @@ -49,7 +49,7 @@ def Input( value, set_value = idom.hooks.use_state(value) def on_change(event: Dict[str, Any]) -> None: - value = event["value"] + value = event["target"]["value"] set_value(value) if not value and ignore_empty: return diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index b62cdc53d..8ecb705ee 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -238,8 +238,8 @@ def Input(message=None): message_ref.current = message async def on_change(event): - if event["value"] == "this is a test": - set_message(event["value"]) + if event["target"]["value"] == "this is a test": + set_message(event["target"]["value"]) if message is None: return idom.html.input({"id": "input", "onChange": on_change}) @@ -506,7 +506,6 @@ async def effect(): with idom.Layout(ComponentWithAsyncEffect()) as layout: await layout.render() - cleanup_ran.wait() component_hook.latest.schedule_render() await layout.render()