diff --git a/pynecone/.templates/web/utils/state.js b/pynecone/.templates/web/utils/state.js index ee58fa269..1d0ffaf1c 100644 --- a/pynecone/.templates/web/utils/state.js +++ b/pynecone/.templates/web/utils/state.js @@ -90,6 +90,11 @@ export const applyEvent = async (event, router, socket) => { return false; } + if (event.name == "_set_value") { + event.payload.ref.current.value = event.payload.value; + return false; + } + // Send the event to the server. event.token = getToken(); event.router_data = (({ pathname, query }) => ({ pathname, query }))(router); diff --git a/pynecone/__init__.py b/pynecone/__init__.py index 747323d0b..e83556e41 100644 --- a/pynecone/__init__.py +++ b/pynecone/__init__.py @@ -16,6 +16,7 @@ from .event import ( EventChain, console_log, redirect, + set_value, window_alert, ) from .event import FileUpload as upload_files diff --git a/pynecone/components/component.py b/pynecone/components/component.py index c153fc0f3..b5c56b350 100644 --- a/pynecone/components/component.py +++ b/pynecone/components/component.py @@ -181,7 +181,7 @@ class Component(Base, ABC): event_trigger: The event trigger to bind the chain to. value: The value to create the event chain from. state_name: The state to be fully controlled. - full_control: Whether full contorolled or not. + full_control: Whether full controlled or not. Returns: The event chain. @@ -243,7 +243,7 @@ class Component(Base, ABC): events = [ EventSpec( handler=e.handler, - local_args=(EVENT_ARG.name,), + local_args=(EVENT_ARG,), args=get_handler_args(e, arg), ) for e in events @@ -461,10 +461,18 @@ class Component(Base, ABC): ) def _get_hooks(self) -> Optional[str]: + """Get the React hooks for this component. + + Returns: + The hooks for just this component. + """ + ref = self.get_ref() + if ref is not None: + return f"const {ref} = useRef(null);" return None def get_hooks(self) -> Set[str]: - """Get javascript code for react hooks. + """Get the React hooks for this component and its children. Returns: The code that should appear just before returning the rendered component. @@ -483,6 +491,16 @@ class Component(Base, ABC): return code + def get_ref(self) -> Optional[str]: + """Get the name of the ref for the component. + + Returns: + The ref name. + """ + if self.id is None: + return None + return format.format_ref(self.id) + def get_custom_components( self, seen: Optional[Set[str]] = None ) -> Set[CustomComponent]: diff --git a/pynecone/components/forms/input.py b/pynecone/components/forms/input.py index 77c043a46..dc07e9b1d 100644 --- a/pynecone/components/forms/input.py +++ b/pynecone/components/forms/input.py @@ -4,6 +4,7 @@ from typing import Dict from pynecone.components.component import EVENT_ARG from pynecone.components.libs.chakra import ChakraComponent +from pynecone.utils import imports from pynecone.var import Var @@ -48,6 +49,12 @@ class Input(ChakraComponent): # "lg" | "md" | "sm" | "xs" size: Var[str] + def _get_imports(self) -> imports.ImportDict: + return imports.merge_imports( + super()._get_imports(), + {"/utils/state": {"set_val"}}, + ) + @classmethod def get_controlled_triggers(cls) -> Dict[str, Var]: """Get the event triggers that pass the component's value to the handler. @@ -63,6 +70,13 @@ class Input(ChakraComponent): "on_key_up": EVENT_ARG.key, } + def _render(self): + out = super()._render() + ref = self.get_ref() + if ref is not None: + out.add_props(ref=Var.create(ref, is_local=False)) + return out + class InputGroup(ChakraComponent): """The InputGroup component is a component that is used to group a set of inputs.""" diff --git a/pynecone/components/tags/tag.py b/pynecone/components/tags/tag.py index eedee4bb0..f23e975ee 100644 --- a/pynecone/components/tags/tag.py +++ b/pynecone/components/tags/tag.py @@ -73,7 +73,7 @@ class Tag(Base): # Handle event props. elif isinstance(prop, EventChain): - local_args = ",".join(prop.events[0].local_args) + local_args = ",".join(([str(a) for a in prop.events[0].local_args])) if len(prop.events) == 1 and prop.events[0].upload: # Special case for upload events. diff --git a/pynecone/event.py b/pynecone/event.py index cce531961..6a9eeeecb 100644 --- a/pynecone/event.py +++ b/pynecone/event.py @@ -23,7 +23,7 @@ class Event(Base): router_data: Dict[str, Any] = {} # The event payload. - payload: Dict[str, Any] = {} + payload: Dict[Any, Any] = {} class EventHandler(Base): @@ -54,21 +54,18 @@ class EventHandler(Base): """ # Get the function args. fn_args = inspect.getfullargspec(self.fn).args[1:] + fn_args = (Var.create_safe(arg) for arg in fn_args) # Construct the payload. values = [] for arg in args: - # If it is a Var, add the full name. - if isinstance(arg, Var): - values.append(arg.full_name) - continue - + # Special case for file uploads. if isinstance(arg, FileUpload): return EventSpec(handler=self, upload=True) # Otherwise, convert to JSON. try: - values.append(format.json_dumps(arg)) + values.append(Var.create(arg)) except TypeError as e: raise TypeError( f"Arguments to event handlers must be Vars or JSON-serializable. Got {arg} of type {type(arg)}." @@ -90,10 +87,10 @@ class EventSpec(Base): handler: EventHandler # The local arguments on the frontend. - local_args: Tuple[str, ...] = () + local_args: Tuple[Var, ...] = () # The arguments to pass to the function. - args: Tuple[Any, ...] = () + args: Tuple[Tuple[Var, Var], ...] = () # Whether to upload files. upload: bool = False @@ -142,7 +139,31 @@ class FileUpload(Base): # Special server-side events. -def redirect(path: str) -> EventSpec: +def server_side(name: str, **kwargs) -> EventSpec: + """A server-side event. + + Args: + name: The name of the event. + **kwargs: The arguments to pass to the event. + + Returns: + An event spec for a server-side event. + """ + + def fn(): + return None + + fn.__qualname__ = name + return EventSpec( + handler=EventHandler(fn=fn), + args=tuple( + (Var.create_safe(k), Var.create_safe(v, is_string=type(v) is str)) + for k, v in kwargs.items() + ), + ) + + +def redirect(path: Union[str, Var[str]]) -> EventSpec: """Redirect to a new path. Args: @@ -151,18 +172,10 @@ def redirect(path: str) -> EventSpec: Returns: An event to redirect to the path. """ - - def fn(): - return None - - fn.__qualname__ = "_redirect" - return EventSpec( - handler=EventHandler(fn=fn), - args=(("path", path),), - ) + return server_side("_redirect", path=path) -def console_log(message: str) -> EventSpec: +def console_log(message: Union[str, Var[str]]) -> EventSpec: """Do a console.log on the browser. Args: @@ -171,18 +184,10 @@ def console_log(message: str) -> EventSpec: Returns: An event to log the message. """ - - def fn(): - return None - - fn.__qualname__ = "_console" - return EventSpec( - handler=EventHandler(fn=fn), - args=(("message", message),), - ) + return server_side("_console", message=message) -def window_alert(message: str) -> EventSpec: +def window_alert(message: Union[str, Var[str]]) -> EventSpec: """Create a window alert on the browser. Args: @@ -191,14 +196,21 @@ def window_alert(message: str) -> EventSpec: Returns: An event to alert the message. """ + return server_side("_alert", message=message) - def fn(): - return None - fn.__qualname__ = "_alert" - return EventSpec( - handler=EventHandler(fn=fn), - args=(("message", message),), +def set_value(ref: str, value: Any) -> EventSpec: + """Set the value of a ref. + + Args: + ref: The ref. + value: The value to set. + + Returns: + An event to set the ref. + """ + return server_side( + "_set_value", ref=Var.create_safe(format.format_ref(ref)), value=value ) @@ -306,7 +318,7 @@ def call_event_fn(fn: Callable, arg: Var) -> List[EventSpec]: return events -def get_handler_args(event_spec: EventSpec, arg: Var) -> Tuple[Tuple[str, str], ...]: +def get_handler_args(event_spec: EventSpec, arg: Var) -> Tuple[Tuple[Var, Var], ...]: """Get the handler args for the given event spec. Args: @@ -324,7 +336,7 @@ def get_handler_args(event_spec: EventSpec, arg: Var) -> Tuple[Tuple[str, str], raise ValueError( f"Event handler has an invalid signature, needed a method with a parameter, got {event_spec.handler}." ) - return event_spec.args if len(args) > 2 else ((args[1], arg.name),) + return event_spec.args if len(args) > 2 else ((Var.create_safe(args[1]), arg),) def fix_events( @@ -339,8 +351,6 @@ def fix_events( Returns: The fixed events. """ - from pynecone.event import Event, EventHandler, EventSpec - # If the event handler returns nothing, return an empty list. if events is None: return [] @@ -359,7 +369,7 @@ def fix_events( e = e() assert isinstance(e, EventSpec), f"Unexpected event type, {type(e)}." name = format.format_event_handler(e.handler) - payload = dict(e.args) + payload = {k.name: v.name for k, v in e.args} # Create an event and append it to the list. out.append( diff --git a/pynecone/utils/format.py b/pynecone/utils/format.py index ab6ccddac..957f8356c 100644 --- a/pynecone/utils/format.py +++ b/pynecone/utils/format.py @@ -286,7 +286,12 @@ def format_event(event_spec: EventSpec) -> str: Returns: The compiled event. """ - args = ",".join([":".join((name, val)) for name, val in event_spec.args]) + args = ",".join( + [ + ":".join((name.name, json.dumps(val.name) if val.is_string else val.name)) + for name, val in event_spec.args + ] + ) return f"E(\"{format_event_handler(event_spec.handler)}\", {wrap(args, '{')})" @@ -398,6 +403,20 @@ def format_state(value: Any) -> Dict: ) +def format_ref(ref: str) -> str: + """Format a ref. + + Args: + ref: The ref to format. + + Returns: + The formatted ref. + """ + # Replace all non-word characters with underscores. + clean_ref = re.sub(r"[^\w]+", "_", ref) + return f"ref_{clean_ref}" + + def json_dumps(obj: Any) -> str: """Takes an object and returns a jsonified string. diff --git a/pynecone/var.py b/pynecone/var.py index d80ce9197..5de4c783d 100644 --- a/pynecone/var.py +++ b/pynecone/var.py @@ -105,6 +105,24 @@ class Var(ABC): return BaseVar(name=name, type_=type_, is_local=is_local, is_string=is_string) + @classmethod + def create_safe( + cls, value: Any, is_local: bool = True, is_string: bool = False + ) -> Var: + """Create a var from a value, guaranteeing that it is not None. + + Args: + value: The value to create the var from. + is_local: Whether the var is local. + is_string: Whether the var is a string literal. + + Returns: + The var. + """ + var = cls.create(value, is_local=is_local, is_string=is_string) + assert var is not None + return var + @classmethod def __class_getitem__(cls, type_: str) -> _GenericAlias: """Get a typed var. diff --git a/pyproject.toml b/pyproject.toml index db14b5171..bc4703369 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pynecone" -version = "0.1.27" +version = "0.1.28" description = "Web apps in pure Python." license = "Apache-2.0" authors = [ diff --git a/scripts/integration.sh b/scripts/integration.sh index c2c666e80..1f32f0073 100644 --- a/scripts/integration.sh +++ b/scripts/integration.sh @@ -15,9 +15,9 @@ while ! nc -z localhost 3000 || ! lsof -i :8000 >/dev/null; do echo "Error: Server process with PID $pid exited early" break fi - if ((wait_time >= 200)); then + if ((wait_time >= 300)); then echo "Error: Timeout waiting for ports 3000 and 8000 to become available" - break + exit 1 fi sleep 5 ((wait_time += 5)) diff --git a/tests/components/test_tag.py b/tests/components/test_tag.py index 99312cc49..ee0bda1e6 100644 --- a/tests/components/test_tag.py +++ b/tests/components/test_tag.py @@ -3,7 +3,7 @@ from typing import Any, Dict import pytest from pynecone.components.tags import CondTag, Tag -from pynecone.event import EventChain, EventHandler, EventSpec +from pynecone.event import EVENT_ARG, EventChain, EventHandler, EventSpec from pynecone.var import BaseVar, Var @@ -32,12 +32,12 @@ def mock_event(arg): events=[ EventSpec( handler=EventHandler(fn=mock_event), - local_args=("e",), - args=(("arg", "e.target.value"),), + local_args=(EVENT_ARG,), + args=((Var.create_safe("arg"), EVENT_ARG.target.value),), ) ] ), - '{(e) => Event([E("mock_event", {arg:e.target.value})])}', + '{(_e) => Event([E("mock_event", {arg:_e.target.value})])}', ), ({"a": "red", "b": "blue"}, '{{"a": "red", "b": "blue"}}'), (BaseVar(name="var", type_="int"), "{var}"), diff --git a/tests/test_event.py b/tests/test_event.py index 69023bc49..6bf978020 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -73,6 +73,9 @@ def test_event_redirect(): assert isinstance(spec, EventSpec) assert spec.handler.fn.__qualname__ == "_redirect" assert spec.args == (("path", "/path"),) + assert format.format_event(spec) == 'E("_redirect", {path:"/path"})' + spec = event.redirect(Var.create_safe("path")) + assert format.format_event(spec) == 'E("_redirect", {path:path})' def test_event_console_log(): @@ -81,6 +84,9 @@ def test_event_console_log(): assert isinstance(spec, EventSpec) assert spec.handler.fn.__qualname__ == "_console" assert spec.args == (("message", "message"),) + assert format.format_event(spec) == 'E("_console", {message:"message"})' + spec = event.console_log(Var.create_safe("message")) + assert format.format_event(spec) == 'E("_console", {message:message})' def test_event_window_alert(): @@ -89,3 +95,22 @@ def test_event_window_alert(): assert isinstance(spec, EventSpec) assert spec.handler.fn.__qualname__ == "_alert" assert spec.args == (("message", "message"),) + assert format.format_event(spec) == 'E("_alert", {message:"message"})' + spec = event.window_alert(Var.create_safe("message")) + assert format.format_event(spec) == 'E("_alert", {message:message})' + + +def test_set_value(): + """Test the event window alert function.""" + spec = event.set_value("input1", "") + assert isinstance(spec, EventSpec) + assert spec.handler.fn.__qualname__ == "_set_value" + assert spec.args == ( + ("ref", Var.create_safe("ref_input1")), + ("value", ""), + ) + assert format.format_event(spec) == 'E("_set_value", {ref:ref_input1,value:""})' + spec = event.set_value("input1", Var.create_safe("message")) + assert ( + format.format_event(spec) == 'E("_set_value", {ref:ref_input1,value:message})' + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index e24f5fb2f..423941b4a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -337,3 +337,23 @@ def test_create_config(app_name, expected_config_name, mocker): tmpl_mock.format.assert_called_with( app_name=app_name, config_name=expected_config_name ) + + +@pytest.mark.parametrize( + "name,expected", + [ + ("input1", "ref_input1"), + ("input 1", "ref_input_1"), + ("input-1", "ref_input_1"), + ("input_1", "ref_input_1"), + ("a long test?1! name", "ref_a_long_test_1_name"), + ], +) +def test_format_ref(name, expected): + """Test formatting a ref. + + Args: + name: The name to format. + expected: The expected formatted name. + """ + assert format.format_ref(name) == expected