From 00479362df4e7ecbb1e70a2323de844b895ca5d2 Mon Sep 17 00:00:00 2001 From: Elijah Ahianyo Date: Thu, 2 Feb 2023 08:22:44 +0000 Subject: [PATCH] Allow conditional props (#359) --- pynecone/__init__.py | 1 + pynecone/components/__init__.py | 20 +++++++++++-- pynecone/components/tags/tag.py | 11 +++++-- pynecone/propcond.py | 51 +++++++++++++++++++++++++++++++++ pynecone/utils.py | 21 ++++++++++++-- tests/compiler/test_compiler.py | 9 ++++-- tests/components/test_tag.py | 22 ++++++++++++-- tests/conftest.py | 15 ++++++++++ tests/test_app.py | 15 ++++++---- tests/test_propcond.py | 35 ++++++++++++++++++++++ tests/test_utils.py | 7 +++-- 11 files changed, 189 insertions(+), 18 deletions(-) create mode 100644 pynecone/propcond.py create mode 100644 tests/conftest.py create mode 100644 tests/test_propcond.py diff --git a/pynecone/__init__.py b/pynecone/__init__.py index ce37ad760..72dc7e2cc 100644 --- a/pynecone/__init__.py +++ b/pynecone/__init__.py @@ -16,3 +16,4 @@ from .model import Model, session from .state import ComputedVar as var from .state import State from .style import toggle_color_mode +from .var import Var diff --git a/pynecone/components/__init__.py b/pynecone/components/__init__.py index 6f88ec843..b20e71462 100644 --- a/pynecone/components/__init__.py +++ b/pynecone/components/__init__.py @@ -1,8 +1,7 @@ """Import all the components.""" from pynecone import utils -from pynecone.event import EventSpec -from pynecone.var import Var +from pynecone.propcond import PropCond from .component import Component from .datadisplay import * @@ -25,6 +24,7 @@ locals().update( } ) + # Add responsive styles shortcuts. def mobile_only(*children, **props): """Create a component that is only visible on mobile. @@ -89,3 +89,19 @@ def mobile_and_tablet(*children, **props): The component. """ return Box.create(*children, **props, display=["block", "block", "block", "none"]) + + +def cond(cond_var, c1, c2=None): + """Create a conditional component or Prop. + + Args: + cond_var: The cond to determine which component to render. + c1: The component or prop to render if the cond_var is true. + c2: The component or prop to render if the cond_var is false. + + Returns: + The conditional component. + """ + if isinstance(c1, Component) and isinstance(c2, Component): + return Cond.create(cond_var, c1, c2) + return PropCond.create(cond_var, c1, c2) diff --git a/pynecone/components/tags/tag.py b/pynecone/components/tags/tag.py index 633a9997f..513cef436 100644 --- a/pynecone/components/tags/tag.py +++ b/pynecone/components/tags/tag.py @@ -12,6 +12,7 @@ from plotly.io import to_json from pynecone import utils from pynecone.base import Base +from pynecone.propcond import PropCond from pynecone.event import EventChain from pynecone.var import Var @@ -47,7 +48,7 @@ class Tag(Base): @staticmethod def format_prop( - prop: Union[Var, EventChain, ComponentStyle, str], + prop: Union[Var, EventChain, ComponentStyle, PropCond, str], ) -> Union[int, float, str]: """Format a prop. @@ -71,6 +72,10 @@ class Tag(Base): events = ",".join([utils.format_event(event) for event in prop.events]) prop = f"({local_args}) => Event([{events}])" + # Handle conditional props. + elif isinstance(prop, PropCond): + return str(prop) + # Handle other types. elif isinstance(prop, str): if utils.is_wrapped(prop, "{"): @@ -85,7 +90,9 @@ class Tag(Base): if isinstance(prop, dict): # Convert any var keys to strings. prop = { - key: str(val) if isinstance(val, Var) else val + key: str(val) + if isinstance(val, Var) or isinstance(val, PropCond) + else val for key, val in prop.items() } diff --git a/pynecone/propcond.py b/pynecone/propcond.py new file mode 100644 index 000000000..1352dcae4 --- /dev/null +++ b/pynecone/propcond.py @@ -0,0 +1,51 @@ +"""Create a Prop Condition.""" +from typing import Any + +from pynecone import utils +from pynecone.base import Base +from pynecone.var import Var + + +class PropCond(Base): + """A conditional prop.""" + + # The condition to determine which prop to render. + cond: Var[Any] + + # The prop to render if the condition is true. + prop1: Any + + # The prop to render if the condition is false. + prop2: Any + + @classmethod + def create(cls, cond: Var, prop1: Any, prop2: Any = None): + """Create a conditional Prop. + + Args: + cond: The cond to determine which prop to render. + prop1: The prop value to render if the cond is true. + prop2: The prop value to render if the cond is false. + + Returns: + The conditional Prop. + """ + return cls( + cond=cond, + prop1=prop1, + prop2=prop2, + ) + + def __str__(self) -> str: + """Render the prop as a React string. + + Returns: + The React code to render the prop. + """ + assert self.cond is not None, "The condition must be set." + return utils.format_cond( + cond=self.cond.full_name, + true_value=self.prop1, + false_value=self.prop2, + is_prop=True, + ) diff --git a/pynecone/utils.py b/pynecone/utils.py index 7c4d61f3d..67bd545f2 100644 --- a/pynecone/utils.py +++ b/pynecone/utils.py @@ -1,4 +1,5 @@ """General utility functions.""" + from __future__ import annotations import contextlib @@ -17,6 +18,7 @@ from collections import defaultdict from pathlib import Path from subprocess import DEVNULL, PIPE, STDOUT from types import ModuleType +from typing import _GenericAlias # type: ignore from typing import ( TYPE_CHECKING, Any, @@ -983,7 +985,11 @@ def format_route(route: str) -> str: def format_cond( - cond: str, true_value: str, false_value: str = '""', is_nested: bool = False + cond: str, + true_value: str, + false_value: str = '""', + is_nested: bool = False, + is_prop=False, ) -> str: """Format a conditional expression. @@ -992,11 +998,22 @@ def format_cond( true_value: The value to return if the cond is true. false_value: The value to return if the cond is false. is_nested: Whether the cond is nested. + is_prop: Whether the cond is a prop Returns: The formatted conditional expression. """ - expr = f"{cond} ? {true_value} : {false_value}" + if is_prop: + if isinstance(true_value, str): + true_value = wrap(true_value, "'") + if isinstance(false_value, str): + false_value = wrap(false_value, "'") + expr = f"{cond} ? {true_value} : {false_value}".replace("{", "").replace( + "}", "" + ) + else: + expr = f"{cond} ? {true_value} : {false_value}" + if not is_nested: expr = wrap(expr, "{") return expr diff --git a/tests/compiler/test_compiler.py b/tests/compiler/test_compiler.py index 2d03f1cae..a0b8408f3 100644 --- a/tests/compiler/test_compiler.py +++ b/tests/compiler/test_compiler.py @@ -41,14 +41,19 @@ def test_compile_import_statement(lib: str, fields: Set[str], output: str): ), ], ) -def test_compile_imports(import_dict: utils.ImportDict, output: str): +def test_compile_imports( + import_dict: utils.ImportDict, output: str, windows_platform: bool +): """Test the compile_imports function. Args: import_dict: The import dictionary. output: The expected output. + windows_platform: whether system is windows. """ - assert utils.compile_imports(import_dict) == output + assert utils.compile_imports(import_dict) == ( + output.replace("\n", "\r\n") if windows_platform else output + ) @pytest.mark.parametrize( diff --git a/tests/components/test_tag.py b/tests/components/test_tag.py index d2b96a21f..eb3229643 100644 --- a/tests/components/test_tag.py +++ b/tests/components/test_tag.py @@ -1,3 +1,4 @@ +import platform from typing import Dict import pytest @@ -6,6 +7,7 @@ from pynecone.components import Box from pynecone.components.tags import CondTag, IterTag, Tag from pynecone.event import EventChain, EventHandler, EventSpec from pynecone.var import BaseVar, Var +from pynecone.propcond import PropCond def mock_event(arg): @@ -40,6 +42,14 @@ def mock_event(arg): ), '{(e) => Event([E("mock_event", {arg:e.target.value})])}', ), + ( + PropCond.create( + cond=BaseVar(name="random_var", type_=str), + prop1="true_value", + prop2="false_value", + ), + "{random_var ? 'true_value' : 'false_value'}", + ), ], ) def test_format_value(prop: Var, formatted: str): @@ -61,14 +71,17 @@ def test_format_value(prop: Var, formatted: str): ({"key": True, "key2": "value2"}, 'key={true}\nkey2="value2"'), ], ) -def test_format_props(props: Dict[str, Var], formatted: str): +def test_format_props(props: Dict[str, Var], formatted: str, windows_platform: bool): """Test that the formatted props are correct. Args: props: The props to test. formatted: The expected formatted props. + windows_platform: Whether the system is windows. """ - assert Tag(props=props).format_props() == formatted + assert Tag(props=props).format_props() == ( + formatted.replace("\n", "\r\n") if windows_platform else formatted + ) @pytest.mark.parametrize( @@ -123,13 +136,16 @@ def test_add_props(): ), ], ) -def test_format_tag(tag: Tag, expected: str): +def test_format_tag(tag: Tag, expected: str, windows_platform: bool): """Test that the formatted tag is correct. Args: tag: The tag to test. expected: The expected formatted tag. + windows_platform: Whether the system is windows. """ + + expected = expected.replace("\n", "\r\n") if windows_platform else expected assert str(tag) == expected diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..8e15c0a74 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +"""Test fixtures.""" +import platform +from typing import Generator + +import pytest + + +@pytest.fixture(scope="function") +def windows_platform() -> Generator: + """Check if system is windows. + + Yields: + whether system is windows. + """ + yield platform.system() == "Windows" diff --git a/tests/test_app.py b/tests/test_app.py index 2e082cfc8..0316d94b0 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,3 +1,4 @@ +import os.path from typing import List, Tuple, Type import pytest @@ -88,28 +89,32 @@ def test_add_page_default_route(app: App, index_page, about_page): assert set(app.pages.keys()) == {"index", "about"} -def test_add_page_set_route(app: App, index_page): +def test_add_page_set_route(app: App, index_page, windows_platform: bool): """Test adding a page to an app. Args: app: The app to test. index_page: The index page. + windows_platform: Whether the system is windows. """ + route = "\\test" if windows_platform else "/test" assert app.pages == {} - app.add_page(index_page, route="/test") + app.add_page(index_page, route=route) assert set(app.pages.keys()) == {"test"} -def test_add_page_set_route_nested(app: App, index_page): +def test_add_page_set_route_nested(app: App, index_page, windows_platform: bool): """Test adding a page to an app. Args: app: The app to test. index_page: The index page. + windows_platform: Whether the system is windows. """ + route = "\\test\\nested" if windows_platform else "/test/nested" assert app.pages == {} - app.add_page(index_page, route="/test/nested") - assert set(app.pages.keys()) == {"test/nested"} + app.add_page(index_page, route=route) + assert set(app.pages.keys()) == {route.strip(os.path.sep)} def test_initialize_with_state(TestState: Type[State]): diff --git a/tests/test_propcond.py b/tests/test_propcond.py new file mode 100644 index 000000000..3c90653a2 --- /dev/null +++ b/tests/test_propcond.py @@ -0,0 +1,35 @@ +from typing import Any + +import pytest + +from pynecone.propcond import PropCond +from pynecone.var import BaseVar, Var +from pynecone.utils import wrap + + +@pytest.mark.parametrize( + "prop1,prop2", + [ + (1, 3), + (1, "text"), + ("text1", "text2"), + ], +) +def test_validate_propcond(prop1: Any, prop2: Any): + """Test the creation of conditional props + + Args: + prop1: truth condition value + prop2: false condition value + + """ + prop_cond = PropCond.create( + cond=BaseVar(name="cond_state.value", type_=str), prop1=prop1, prop2=prop2 + ) + + expected_prop1 = wrap(prop1, "'") if isinstance(prop1, str) else prop1 + expected_prop2 = wrap(prop2, "'") if isinstance(prop2, str) else prop2 + + assert str(prop_cond) == ( + "{cond_state.value ? " f"{expected_prop1} : " f"{expected_prop2}" "}" + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 0e56e6a8a..743f6c489 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -145,15 +145,18 @@ def test_wrap(text: str, open: str, expected: str, check_first: bool, num: int): (" hello\n world", 2, " hello\n world\n"), ], ) -def test_indent(text: str, indent_level: int, expected: str): +def test_indent(text: str, indent_level: int, expected: str, windows_platform: bool): """Test indenting a string. Args: text: The text to indent. indent_level: The number of spaces to indent by. expected: The expected output string. + windows_platform: Whether the system is windows. """ - assert utils.indent(text, indent_level) == expected + assert utils.indent(text, indent_level) == ( + expected.replace("\n", "\r\n") if windows_platform else expected + ) @pytest.mark.parametrize(