diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 261d948c0..ac50b6d82 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -30,6 +30,7 @@ Unreleased - :pull:`1281` - Added ``reactpy.Vdom`` primitive interface for creating VDOM dictionaries. - :pull:`1281` - Added type hints to ``reactpy.html`` attributes. - :pull:`1285` - Added support for nested components in web modules +- :pull:`1289` - Added support for inline JavaScript as event handlers or other attributes that expect a callable **Changed** diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts index 3c0330a07..148a3486c 100644 --- a/src/js/packages/@reactpy/client/src/types.ts +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -53,6 +53,7 @@ export type ReactPyVdom = { children?: (ReactPyVdom | string)[]; error?: string; eventHandlers?: { [key: string]: ReactPyVdomEventHandler }; + inlineJavaScript?: { [key: string]: string }; importSource?: ReactPyVdomImportSource; }; diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index cae706787..4bd882ff4 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -189,6 +189,12 @@ export function createAttributes( createEventHandler(client, name, handler), ), ), + ...Object.fromEntries( + Object.entries(model.inlineJavaScript || {}).map( + ([name, inlineJavaScript]) => + createInlineJavaScript(name, inlineJavaScript), + ), + ), }), ); } @@ -198,23 +204,51 @@ function createEventHandler( name: string, { target, preventDefault, stopPropagation }: ReactPyVdomEventHandler, ): [string, () => void] { - return [ - name, - function (...args: any[]) { - const data = Array.from(args).map((value) => { - if (!(typeof value === "object" && value.nativeEvent)) { - return value; - } - const event = value as React.SyntheticEvent; - if (preventDefault) { - event.preventDefault(); - } - if (stopPropagation) { - event.stopPropagation(); - } - return serializeEvent(event.nativeEvent); - }); - client.sendMessage({ type: "layout-event", data, target }); - }, - ]; + const eventHandler = function (...args: any[]) { + const data = Array.from(args).map((value) => { + if (!(typeof value === "object" && value.nativeEvent)) { + return value; + } + const event = value as React.SyntheticEvent; + if (preventDefault) { + event.preventDefault(); + } + if (stopPropagation) { + event.stopPropagation(); + } + return serializeEvent(event.nativeEvent); + }); + client.sendMessage({ type: "layout-event", data, target }); + }; + eventHandler.isHandler = true; + return [name, eventHandler]; +} + +function createInlineJavaScript( + name: string, + inlineJavaScript: string, +): [string, () => void] { + /* Function that will execute the string-like InlineJavaScript + via eval in the most appropriate way */ + const wrappedExecutable = function (...args: any[]) { + function handleExecution(...args: any[]) { + const evalResult = eval(inlineJavaScript); + if (typeof evalResult == "function") { + return evalResult(...args); + } + } + if (args.length > 0 && args[0] instanceof Event) { + /* If being triggered by an event, set the event's current + target to "this". This ensures that inline + javascript statements such as the following work: + html.button({"onclick": 'this.value = "Clicked!"'}, "Click Me")*/ + return handleExecution.call(args[0].currentTarget, ...args); + } else { + /* If not being triggered by an event, do not set "this" and + just call normally */ + return handleExecution(...args); + } + }; + wrappedExecutable.isHandler = false; + return [name, wrappedExecutable]; } diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index a32f97083..a81ecc6d7 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -262,6 +262,10 @@ def _render_model_attributes( attrs = raw_model["attributes"].copy() new_state.model.current["attributes"] = attrs + if "inlineJavaScript" in raw_model: + inline_javascript = raw_model["inlineJavaScript"].copy() + new_state.model.current["inlineJavaScript"] = inline_javascript + if old_state is None: self._render_model_event_handlers_without_old_state( new_state, handlers_by_event diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 7ecddcf0e..8d70af53d 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import re from collections.abc import Mapping, Sequence from typing import ( Any, @@ -23,12 +24,16 @@ EventHandlerDict, EventHandlerType, ImportSourceDict, + InlineJavaScript, + InlineJavaScriptDict, VdomAttributes, VdomChildren, VdomDict, VdomJson, ) +EVENT_ATTRIBUTE_PATTERN = re.compile(r"^on[A-Z]\w+") + VDOM_JSON_SCHEMA = { "$schema": "http://json-schema.org/draft-07/schema", "$ref": "#/definitions/element", @@ -42,6 +47,7 @@ "children": {"$ref": "#/definitions/elementChildren"}, "attributes": {"type": "object"}, "eventHandlers": {"$ref": "#/definitions/elementEventHandlers"}, + "inlineJavaScript": {"$ref": "#/definitions/elementInlineJavaScripts"}, "importSource": {"$ref": "#/definitions/importSource"}, }, # The 'tagName' is required because its presence is a useful indicator of @@ -71,6 +77,12 @@ }, "required": ["target"], }, + "elementInlineJavaScripts": { + "type": "object", + "patternProperties": { + ".*": "str", + }, + }, "importSource": { "type": "object", "properties": { @@ -160,7 +172,9 @@ def __call__( """The entry point for the VDOM API, for example reactpy.html().""" attributes, children = separate_attributes_and_children(attributes_and_children) key = attributes.get("key", None) - attributes, event_handlers = separate_attributes_and_event_handlers(attributes) + attributes, event_handlers, inline_javascript = ( + separate_attributes_handlers_and_inline_javascript(attributes) + ) if REACTPY_CHECK_JSON_ATTRS.current: json.dumps(attributes) @@ -180,6 +194,9 @@ def __call__( **({"children": children} if children else {}), **({"attributes": attributes} if attributes else {}), **({"eventHandlers": event_handlers} if event_handlers else {}), + **( + {"inlineJavaScript": inline_javascript} if inline_javascript else {} + ), **({"importSource": self.import_source} if self.import_source else {}), } @@ -212,26 +229,26 @@ def separate_attributes_and_children( return _attributes, _children -def separate_attributes_and_event_handlers( +def separate_attributes_handlers_and_inline_javascript( attributes: Mapping[str, Any], -) -> tuple[VdomAttributes, EventHandlerDict]: +) -> tuple[VdomAttributes, EventHandlerDict, InlineJavaScriptDict]: _attributes: VdomAttributes = {} _event_handlers: dict[str, EventHandlerType] = {} + _inline_javascript: dict[str, InlineJavaScript] = {} for k, v in attributes.items(): - handler: EventHandlerType - if callable(v): - handler = EventHandler(to_event_handler_function(v)) + _event_handlers[k] = EventHandler(to_event_handler_function(v)) elif isinstance(v, EventHandler): - handler = v + _event_handlers[k] = v + elif EVENT_ATTRIBUTE_PATTERN.match(k) and isinstance(v, str): + _inline_javascript[k] = InlineJavaScript(v) + elif isinstance(v, InlineJavaScript): + _inline_javascript[k] = v else: _attributes[k] = v - continue - - _event_handlers[k] = handler - return _attributes, _event_handlers + return _attributes, _event_handlers, _inline_javascript def _flatten_children(children: Sequence[Any]) -> list[Any]: diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py index cdac48c7e..c5709790f 100644 --- a/src/reactpy/transforms.py +++ b/src/reactpy/transforms.py @@ -37,14 +37,19 @@ def normalize_style_attributes(self, vdom: dict[str, Any]) -> None: } @staticmethod - def html_props_to_reactjs(vdom: VdomDict) -> None: - """Convert HTML prop names to their ReactJS equivalents.""" - if "attributes" in vdom: - items = cast(VdomAttributes, vdom["attributes"].items()) - vdom["attributes"] = cast( - VdomAttributes, - {REACT_PROP_SUBSTITUTIONS.get(k, k): v for k, v in items}, - ) + def _attributes_to_reactjs(attributes: VdomAttributes): + """Convert HTML attribute names to their ReactJS equivalents. + + This method is private because it is called prior to instantiating a + Vdom class from a parsed html string, so it does not need to be called + as part of this class's instantiation (see comments in __init__ above). + """ + attrs = cast(VdomAttributes, attributes.items()) + attrs = cast( + VdomAttributes, + {REACT_PROP_SUBSTITUTIONS.get(k, k): v for k, v in attrs}, + ) + return attrs @staticmethod def textarea_children_to_prop(vdom: VdomDict) -> None: diff --git a/src/reactpy/types.py b/src/reactpy/types.py index ba8ce31f0..2f0fbed8e 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -768,6 +768,7 @@ class DangerouslySetInnerHTML(TypedDict): "children", "attributes", "eventHandlers", + "inlineJavaScript", "importSource", ] ALLOWED_VDOM_KEYS = { @@ -776,6 +777,7 @@ class DangerouslySetInnerHTML(TypedDict): "children", "attributes", "eventHandlers", + "inlineJavaScript", "importSource", } @@ -788,6 +790,7 @@ class VdomTypeDict(TypedDict): children: NotRequired[Sequence[ComponentType | VdomChild]] attributes: NotRequired[VdomAttributes] eventHandlers: NotRequired[EventHandlerDict] + inlineJavaScript: NotRequired[InlineJavaScriptDict] importSource: NotRequired[ImportSourceDict] @@ -818,6 +821,8 @@ def __getitem__(self, key: Literal["attributes"]) -> VdomAttributes: ... @overload def __getitem__(self, key: Literal["eventHandlers"]) -> EventHandlerDict: ... @overload + def __getitem__(self, key: Literal["inlineJavaScript"]) -> InlineJavaScriptDict: ... + @overload def __getitem__(self, key: Literal["importSource"]) -> ImportSourceDict: ... def __getitem__(self, key: VdomDictKeys) -> Any: return super().__getitem__(key) @@ -839,6 +844,10 @@ def __setitem__( self, key: Literal["eventHandlers"], value: EventHandlerDict ) -> None: ... @overload + def __setitem__( + self, key: Literal["inlineJavaScript"], value: InlineJavaScriptDict + ) -> None: ... + @overload def __setitem__( self, key: Literal["importSource"], value: ImportSourceDict ) -> None: ... @@ -871,6 +880,7 @@ class VdomJson(TypedDict): children: NotRequired[list[Any]] attributes: NotRequired[VdomAttributes] eventHandlers: NotRequired[dict[str, JsonEventTarget]] + inlineJavaScript: NotRequired[dict[str, InlineJavaScript]] importSource: NotRequired[JsonImportSource] @@ -885,6 +895,12 @@ class JsonImportSource(TypedDict): fallback: Any +class InlineJavaScript(str): + """Simple subclass that flags a user's string in ReactPy VDOM attributes as executable JavaScript.""" + + pass + + class EventHandlerFunc(Protocol): """A coroutine which can handle event data""" @@ -922,6 +938,12 @@ class EventHandlerType(Protocol): EventHandlerDict: TypeAlias = dict[str, EventHandlerType] """A dict mapping between event names to their handlers""" +InlineJavaScriptMapping = Mapping[str, InlineJavaScript] +"""A generic mapping between attribute names to their inline javascript""" + +InlineJavaScriptDict: TypeAlias = dict[str, InlineJavaScript] +"""A dict mapping between attribute names to their inline javascript""" + class VdomConstructor(Protocol): """Standard function for constructing a :class:`VdomDict`""" diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 2bbe675ac..78629bb3b 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -147,10 +147,11 @@ def _etree_to_vdom( # Recursively call _etree_to_vdom() on all children children = _generate_vdom_children(node, transforms, intercept_links) + attributes = RequiredTransforms._attributes_to_reactjs(dict(node.items())) # Convert the lxml node to a VDOM dict constructor = getattr(html, str(node.tag)) - el = constructor(dict(node.items()), children) + el = constructor(attributes, children) # Perform necessary transformations on the VDOM attributes to meet VDOM spec RequiredTransforms(el, intercept_links) diff --git a/src/reactpy/web/templates/react.js b/src/reactpy/web/templates/react.js index 366be4fd0..b4970d320 100644 --- a/src/reactpy/web/templates/react.js +++ b/src/reactpy/web/templates/react.js @@ -29,7 +29,7 @@ export function bind(node, config) { function wrapEventHandlers(props) { const newProps = Object.assign({}, props); for (const [key, value] of Object.entries(props)) { - if (typeof value === "function") { + if (typeof value === "function" && value.isHandler) { newProps[key] = makeJsonSafeEventHandler(value); } } diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 310ddc880..262570a74 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -221,3 +221,97 @@ def outer_click_is_not_triggered(event): await inner.click() await poll(lambda: clicked.current).until_is(True) + + +async def test_javascript_event_as_arrow_function(display: DisplayFixture): + @reactpy.component + def App(): + return reactpy.html.div( + reactpy.html.div( + reactpy.html.button( + { + "id": "the-button", + "onClick": '(e) => e.target.innerText = "Thank you!"', + }, + "Click Me", + ), + reactpy.html.div({"id": "the-parent"}), + ) + ) + + await display.show(lambda: App()) + + button = await display.page.wait_for_selector("#the-button", state="attached") + assert await button.inner_text() == "Click Me" + await button.click() + assert await button.inner_text() == "Thank you!" + + +async def test_javascript_event_as_this_statement(display: DisplayFixture): + @reactpy.component + def App(): + return reactpy.html.div( + reactpy.html.div( + reactpy.html.button( + { + "id": "the-button", + "onClick": 'this.innerText = "Thank you!"', + }, + "Click Me", + ), + reactpy.html.div({"id": "the-parent"}), + ) + ) + + await display.show(lambda: App()) + + button = await display.page.wait_for_selector("#the-button", state="attached") + assert await button.inner_text() == "Click Me" + await button.click() + assert await button.inner_text() == "Thank you!" + + +async def test_javascript_event_after_state_update(display: DisplayFixture): + @reactpy.component + def App(): + click_count, set_click_count = reactpy.hooks.use_state(0) + return reactpy.html.div( + {"id": "the-parent"}, + reactpy.html.button( + { + "id": "button-with-reactpy-event", + "onClick": lambda _: set_click_count(click_count + 1), + }, + "Click Me", + ), + reactpy.html.button( + { + "id": "button-with-javascript-event", + "onClick": """javascript: () => { + let parent = document.getElementById("the-parent"); + parent.appendChild(document.createElement("div")); + }""", + }, + "No, Click Me", + ), + *[reactpy.html.div("Clicked") for _ in range(click_count)], + ) + + await display.show(lambda: App()) + + button1 = await display.page.wait_for_selector( + "#button-with-reactpy-event", state="attached" + ) + await button1.click() + await button1.click() + await button1.click() + button2 = await display.page.wait_for_selector( + "#button-with-javascript-event", state="attached" + ) + await button2.click() + await button2.click() + await button2.click() + parent = await display.page.wait_for_selector("#the-parent", state="attached") + generated_divs = await parent.query_selector_all("div") + + assert len(generated_divs) == 6 diff --git a/tests/test_utils.py b/tests/test_utils.py index aa2905c05..e9d2f32f9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -194,6 +194,15 @@ def test_string_to_reactpy(case): "key": "my-key", }, }, + # 9: Includes `inlineJavaScript` attribue + { + "source": """""", + "model": { + "tagName": "button", + "inlineJavaScript": {"onClick": "this.innerText = 'CLICKED'"}, + "children": ["Click Me"], + }, + }, ], ) def test_string_to_reactpy_default_transforms(case): diff --git a/tests/test_web/js_fixtures/callable-prop.js b/tests/test_web/js_fixtures/callable-prop.js new file mode 100644 index 000000000..d16dd333a --- /dev/null +++ b/tests/test_web/js_fixtures/callable-prop.js @@ -0,0 +1,24 @@ +import { h, render } from "https://unpkg.com/preact?module"; +import htm from "https://unpkg.com/htm?module"; + +const html = htm.bind(h); + +export function bind(node, config) { + return { + create: (type, props, children) => h(type, props, ...children), + render: (element) => render(element, node), + unmount: () => render(null, node), + }; +} + +export function Component(props) { + var text = "DEFAULT"; + if (props.setText && typeof props.setText === "function") { + text = props.setText("PREFIX TEXT: "); + } + return html` +
+ ${text} +
+ `; +} diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 9594be4ae..d233396fc 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -12,6 +12,7 @@ assert_reactpy_did_not_log, poll, ) +from reactpy.types import InlineJavaScript from reactpy.web.module import NAME_SOURCE, WebModule JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" @@ -389,6 +390,27 @@ async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture): assert len(form_label) == 1 +async def test_callable_prop_with_javacript(display: DisplayFixture): + module = reactpy.web.module_from_file( + "callable-prop", JS_FIXTURES_DIR / "callable-prop.js" + ) + Component = reactpy.web.export(module, "Component") + + @reactpy.component + def App(): + return Component( + { + "id": "my-div", + "setText": InlineJavaScript('(prefixText) => prefixText + "TEST 123"'), + } + ) + + await display.show(lambda: App()) + + my_div = await display.page.wait_for_selector("#my-div", state="attached") + assert await my_div.inner_text() == "PREFIX TEXT: TEST 123" + + def test_module_from_string(): reactpy.web.module_from_string("temp", "old") with assert_reactpy_did_log(r"Existing web module .* will be replaced with"):