From 0b41dc9aaaa9b0630e80d63eeee98f10d5715634 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 17 Mar 2025 14:21:05 -0600 Subject: [PATCH 01/13] Adds support for web module subcomponent notation --- src/js/packages/@reactpy/client/src/vdom.tsx | 32 +++++++++++++++++-- src/reactpy/web/module.py | 4 +-- .../js_fixtures/subcomponent-notation.js | 14 ++++++++ tests/test_web/test_module.py | 14 ++++++++ 4 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 tests/test_web/js_fixtures/subcomponent-notation.js diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index 25eb9f3e7..dcd1d4e3b 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -68,6 +68,7 @@ function createImportSourceElement(props: { }): any { let type: any; if (props.model.importSource) { + let rootType = props.model.tagName.split(".")[0]; if ( !isImportSourceEqual(props.currentImportSource, props.model.importSource) ) { @@ -78,15 +79,16 @@ function createImportSourceElement(props: { stringifyImportSource(props.model.importSource), ); return null; - } else if (!props.module[props.model.tagName]) { + } else if (!props.module[rootType]) { log.error( "Module from source " + stringifyImportSource(props.currentImportSource) + - ` does not export ${props.model.tagName}`, + ` does not export ${rootType}`, ); return null; } else { - type = props.module[props.model.tagName]; + type = tryGetSubType(props.module, props.model.tagName); + if (!type) return null; } } else { type = props.model.tagName; @@ -103,6 +105,30 @@ function createImportSourceElement(props: { ); } +function tryGetSubType( + module: ReactPyModule, + component: string +) { + let subComponents: string[] = component.split("."); + let rootComponent: string = subComponents[0]; + let subComponentAccessor: string = rootComponent; + let type: any = module[rootComponent]; + + subComponents = subComponents.slice(1); + for (let i = 0; i < subComponents.length; i++) { + let subComponent = subComponents[i]; + subComponentAccessor += "." + subComponent; + type = type[subComponent]; + if (!type) { + console.error( + `Component ${rootComponent} does not have subcomponent ${subComponentAccessor}` + ); + break + } + } + return type; +} + function isImportSourceEqual( source1: ReactPyVdomImportSource, source2: ReactPyVdomImportSource, diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index 04c898338..4c073df58 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -260,14 +260,14 @@ def export( if isinstance(export_names, str): if ( web_module.export_names is not None - and export_names not in web_module.export_names + and export_names.split(".")[0] not in web_module.export_names ): msg = f"{web_module.source!r} does not export {export_names!r}" raise ValueError(msg) return _make_export(web_module, export_names, fallback, allow_children) else: if web_module.export_names is not None: - missing = sorted(set(export_names).difference(web_module.export_names)) + missing = sorted(set([e.split(".")[0] for e in export_names]).difference(web_module.export_names)) if missing: msg = f"{web_module.source!r} does not export {missing!r}" raise ValueError(msg) diff --git a/tests/test_web/js_fixtures/subcomponent-notation.js b/tests/test_web/js_fixtures/subcomponent-notation.js new file mode 100644 index 000000000..55260c217 --- /dev/null +++ b/tests/test_web/js_fixtures/subcomponent-notation.js @@ -0,0 +1,14 @@ +import React from "https://esm.sh/react@19.0" +import ReactDOM from "https://esm.sh/react-dom@19.0/client" +import Form from "https://esm.sh/react-bootstrap@2.10.9/Form"; +export {Form}; + +export function bind(node, config) { + const root = ReactDOM.createRoot(node); + return { + create: (type, props, children) => + React.createElement(type, props, children), + render: (element) => root.render(element, node), + unmount: () => root.unmount() + }; +} \ No newline at end of file diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 4b5f980c4..fc47d6b52 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -268,6 +268,20 @@ async def test_keys_properly_propagated(display: DisplayFixture): assert len(children) == 3 +async def test_subcomponent_notation(display: DisplayFixture): + module = reactpy.web.module_from_file( + "subcomponent-notation", JS_FIXTURES_DIR / "subcomponent-notation.js", + ) + BootstrapFormLabel = reactpy.web.export(module, "Form.Label") + + await display.show( + lambda: BootstrapFormLabel({"htmlFor": "test-123"}, "Test 123") + ) + + await display.page.wait_for_selector(".form-label", state="attached") + # The above will fail due to timeout if it does not work as expected + + 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"): From d311e6a7245a5dc80490b47a48c7689a054d4b4d Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 18 Mar 2025 07:38:12 -0600 Subject: [PATCH 02/13] Adds object-based subcomponent accessor and tests --- src/reactpy/core/vdom.py | 9 +- .../js_fixtures/subcomponent-notation.js | 8 +- tests/test_web/test_module.py | 117 +++++++++++++++++- 3 files changed, 124 insertions(+), 10 deletions(-) diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 6bc28dfd4..c696d17f5 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -134,7 +134,14 @@ def __init__( if module_name: self.__module__ = module_name self.__qualname__ = f"{module_name}.{tag_name}" - + + def __getattr__(self, attr: str) -> Vdom: + return Vdom( + f"{self.__name__}.{attr}", + allow_children=self.allow_children, + import_source=self.import_source + ) + @overload def __call__( self, attributes: VdomAttributes, /, *children: VdomChildren diff --git a/tests/test_web/js_fixtures/subcomponent-notation.js b/tests/test_web/js_fixtures/subcomponent-notation.js index 55260c217..73527c667 100644 --- a/tests/test_web/js_fixtures/subcomponent-notation.js +++ b/tests/test_web/js_fixtures/subcomponent-notation.js @@ -1,14 +1,14 @@ import React from "https://esm.sh/react@19.0" import ReactDOM from "https://esm.sh/react-dom@19.0/client" -import Form from "https://esm.sh/react-bootstrap@2.10.9/Form"; -export {Form}; +import {InputGroup, Form} from "https://esm.sh/react-bootstrap@2.10.2?deps=react@19.0,react-dom@19.0,react-is@19.0&exports=InputGroup,Form"; +export {InputGroup, Form}; export function bind(node, config) { const root = ReactDOM.createRoot(node); return { create: (type, props, children) => - React.createElement(type, props, children), - render: (element) => root.render(element, node), + React.createElement(type, props, ...children), + render: (element) => root.render(element), unmount: () => root.unmount() }; } \ No newline at end of file diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index fc47d6b52..30dccbe52 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -268,18 +268,125 @@ async def test_keys_properly_propagated(display: DisplayFixture): assert len(children) == 3 -async def test_subcomponent_notation(display: DisplayFixture): +async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture): module = reactpy.web.module_from_file( "subcomponent-notation", JS_FIXTURES_DIR / "subcomponent-notation.js", ) - BootstrapFormLabel = reactpy.web.export(module, "Form.Label") + InputGroup, InputGroupText, FormControl, FormLabel = reactpy.web.export( + module, + ["InputGroup", "InputGroup.Text", "Form.Control", "Form.Label"] + ) + + content = reactpy.html.div({"id": "the-parent"}, + InputGroup( + InputGroupText({"id": "basic-addon1"}, "@"), + FormControl({ + "placeholder": "Username", + "aria-label": "Username", + "aria-describedby": "basic-addon1", + }), + ), + + InputGroup( + FormControl({ + "placeholder": "Recipient's username", + "aria-label": "Recipient's username", + "aria-describedby": "basic-addon2", + }), + InputGroupText({"id": "basic-addon2"}, "@example.com"), + ), + + FormLabel({"htmlFor": "basic-url"}, "Your vanity URL"), + InputGroup( + InputGroupText({"id": "basic-addon3"}, + "https://example.com/users/" + ), + FormControl({"id": "basic-url", "aria-describedby": "basic-addon3"}), + ), + + InputGroup( + InputGroupText("$"), + FormControl({"aria-label": "Amount (to the nearest dollar)"}), + InputGroupText(".00"), + ), + + InputGroup( + InputGroupText("With textarea"), + FormControl({"as": "textarea", "aria-label": "With textarea"}), + ) + ) await display.show( - lambda: BootstrapFormLabel({"htmlFor": "test-123"}, "Test 123") + lambda: content ) - await display.page.wait_for_selector(".form-label", state="attached") - # The above will fail due to timeout if it does not work as expected + parent = await display.page.wait_for_selector("#the-parent", state="visible") + input_group_text = await parent.query_selector_all(".input-group-text") + form_control = await parent.query_selector_all(".form-control") + form_label = await parent.query_selector_all(".form-label") + + assert len(input_group_text) == 6 + assert len(form_control) == 5 + assert len(form_label) == 1 + + +async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture): + module = reactpy.web.module_from_file( + "subcomponent-notation", JS_FIXTURES_DIR / "subcomponent-notation.js", + ) + InputGroup, Form = reactpy.web.export(module, ["InputGroup", "Form"]) + + content = reactpy.html.div({"id": "the-parent"}, + InputGroup( + InputGroup.Text({"id": "basic-addon1"}, "@"), + Form.Control({ + "placeholder": "Username", + "aria-label": "Username", + "aria-describedby": "basic-addon1", + }), + ), + + InputGroup( + Form.Control({ + "placeholder": "Recipient's username", + "aria-label": "Recipient's username", + "aria-describedby": "basic-addon2", + }), + InputGroup.Text({"id": "basic-addon2"}, "@example.com"), + ), + + Form.Label({"htmlFor": "basic-url"}, "Your vanity URL"), + InputGroup( + InputGroup.Text({"id": "basic-addon3"}, + "https://example.com/users/" + ), + Form.Control({"id": "basic-url", "aria-describedby": "basic-addon3"}), + ), + + InputGroup( + InputGroup.Text("$"), + Form.Control({"aria-label": "Amount (to the nearest dollar)"}), + InputGroup.Text(".00"), + ), + + InputGroup( + InputGroup.Text("With textarea"), + Form.Control({"as": "textarea", "aria-label": "With textarea"}), + ) + ) + + await display.show( + lambda: content + ) + + parent = await display.page.wait_for_selector("#the-parent", state="visible") + input_group_text = await parent.query_selector_all(".input-group-text") + form_control = await parent.query_selector_all(".form-control") + form_label = await parent.query_selector_all(".form-label") + + assert len(input_group_text) == 6 + assert len(form_control) == 5 + assert len(form_label) == 1 def test_module_from_string(): From 5412757ade8cd1e40068d1fb051dd60e1b525b40 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 18 Mar 2025 08:01:00 -0600 Subject: [PATCH 03/13] Fixes linter/formatting errors --- src/reactpy/web/module.py | 2 +- tests/test_web/test_module.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index 4c073df58..8002c6a09 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -267,7 +267,7 @@ def export( return _make_export(web_module, export_names, fallback, allow_children) else: if web_module.export_names is not None: - missing = sorted(set([e.split(".")[0] for e in export_names]).difference(web_module.export_names)) + missing = sorted({e.split(".")[0] for e in export_names}.difference(web_module.export_names)) if missing: msg = f"{web_module.source!r} does not export {missing!r}" raise ValueError(msg) diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 30dccbe52..7665b967c 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -214,8 +214,8 @@ async def test_keys_properly_propagated(display: DisplayFixture): The `key` property was being lost in its propagation from the server-side ReactPy definition to the front-end JavaScript. - - This property is required for certain JS components, such as the GridLayout from + + This property is required for certain JS components, such as the GridLayout from react-grid-layout. """ module = reactpy.web.module_from_file( @@ -273,7 +273,7 @@ async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture): "subcomponent-notation", JS_FIXTURES_DIR / "subcomponent-notation.js", ) InputGroup, InputGroupText, FormControl, FormLabel = reactpy.web.export( - module, + module, ["InputGroup", "InputGroup.Text", "Form.Control", "Form.Label"] ) @@ -309,7 +309,7 @@ async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture): FormControl({"aria-label": "Amount (to the nearest dollar)"}), InputGroupText(".00"), ), - + InputGroup( InputGroupText("With textarea"), FormControl({"as": "textarea", "aria-label": "With textarea"}), @@ -368,7 +368,7 @@ async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture): Form.Control({"aria-label": "Amount (to the nearest dollar)"}), InputGroup.Text(".00"), ), - + InputGroup( InputGroup.Text("With textarea"), Form.Control({"as": "textarea", "aria-label": "With textarea"}), From 7923573eb9f0f1f1ec3ec1f92db37ec227dda747 Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Tue, 18 Mar 2025 09:15:29 -0600 Subject: [PATCH 04/13] Remove whitespace --- src/reactpy/core/vdom.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index c696d17f5..132024199 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -134,14 +134,14 @@ def __init__( if module_name: self.__module__ = module_name self.__qualname__ = f"{module_name}.{tag_name}" - + def __getattr__(self, attr: str) -> Vdom: return Vdom( f"{self.__name__}.{attr}", allow_children=self.allow_children, import_source=self.import_source ) - + @overload def __call__( self, attributes: VdomAttributes, /, *children: VdomChildren From ffdb04197c2bf1a8de5e22586c79ab7871122ccb Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 18 Mar 2025 20:56:39 -0600 Subject: [PATCH 05/13] More formatting fixes --- src/js/packages/@reactpy/client/src/vdom.tsx | 11 +- src/reactpy/core/vdom.py | 2 +- src/reactpy/web/module.py | 6 +- tests/test_core/test_vdom.py | 6 +- tests/test_utils.py | 6 +- tests/test_web/test_module.py | 158 +++++++++---------- 6 files changed, 96 insertions(+), 93 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index dcd1d4e3b..e97e487db 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -105,15 +105,12 @@ function createImportSourceElement(props: { ); } -function tryGetSubType( - module: ReactPyModule, - component: string -) { +function tryGetSubType(module: ReactPyModule, component: string) { let subComponents: string[] = component.split("."); let rootComponent: string = subComponents[0]; let subComponentAccessor: string = rootComponent; let type: any = module[rootComponent]; - + subComponents = subComponents.slice(1); for (let i = 0; i < subComponents.length; i++) { let subComponent = subComponents[i]; @@ -121,9 +118,9 @@ function tryGetSubType( type = type[subComponent]; if (!type) { console.error( - `Component ${rootComponent} does not have subcomponent ${subComponentAccessor}` + `Component ${rootComponent} does not have subcomponent ${subComponentAccessor}`, ); - break + break; } } return type; diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 132024199..efd7fe67e 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -139,7 +139,7 @@ def __getattr__(self, attr: str) -> Vdom: return Vdom( f"{self.__name__}.{attr}", allow_children=self.allow_children, - import_source=self.import_source + import_source=self.import_source, ) @overload diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index 8002c6a09..bd35f92cb 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -267,7 +267,11 @@ def export( return _make_export(web_module, export_names, fallback, allow_children) else: if web_module.export_names is not None: - missing = sorted({e.split(".")[0] for e in export_names}.difference(web_module.export_names)) + missing = sorted( + {e.split(".")[0] for e in export_names}.difference( + web_module.export_names + ) + ) if missing: msg = f"{web_module.source!r} does not export {missing!r}" raise ValueError(msg) diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index 2bbbf442f..dbf0bbcd6 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -71,11 +71,11 @@ def test_is_vdom(result, value): {"tagName": "div", "attributes": {"tagName": "div"}}, ), ( - reactpy.Vdom("div")((i for i in range(3))), + reactpy.Vdom("div")(i for i in range(3)), {"tagName": "div", "children": [0, 1, 2]}, ), ( - reactpy.Vdom("div")((x**2 for x in [1, 2, 3])), + reactpy.Vdom("div")(x**2 for x in [1, 2, 3]), {"tagName": "div", "children": [1, 4, 9]}, ), ( @@ -293,7 +293,7 @@ def test_invalid_vdom(value, error_message_pattern): @pytest.mark.skipif(not REACTPY_DEBUG.current, reason="Only warns in debug mode") def test_warn_cannot_verify_keypath_for_genereators(): with pytest.warns(UserWarning) as record: - reactpy.Vdom("div")((1 for i in range(10))) + reactpy.Vdom("div")(1 for i in range(10)) assert len(record) == 1 assert ( record[0] diff --git a/tests/test_utils.py b/tests/test_utils.py index e494c29b3..aa2905c05 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -188,7 +188,11 @@ def test_string_to_reactpy(case): # 8: Infer ReactJS `key` from the `key` attribute { "source": '
', - "model": {"tagName": "div", "attributes": {"key": "my-key"}, "key": "my-key"}, + "model": { + "tagName": "div", + "attributes": {"key": "my-key"}, + "key": "my-key", + }, }, ], ) diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 7665b967c..49cbe476e 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -224,44 +224,47 @@ async def test_keys_properly_propagated(display: DisplayFixture): GridLayout = reactpy.web.export(module, "GridLayout") await display.show( - lambda: GridLayout({ - "layout": [ - { - "i": "a", - "x": 0, - "y": 0, - "w": 1, - "h": 2, - "static": True, - }, - { - "i": "b", - "x": 1, - "y": 0, - "w": 3, - "h": 2, - "minW": 2, - "maxW": 4, - }, - { - "i": "c", - "x": 4, - "y": 0, - "w": 1, - "h": 2, - } - ], - "cols": 12, - "rowHeight": 30, - "width": 1200, - }, + lambda: GridLayout( + { + "layout": [ + { + "i": "a", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "static": True, + }, + { + "i": "b", + "x": 1, + "y": 0, + "w": 3, + "h": 2, + "minW": 2, + "maxW": 4, + }, + { + "i": "c", + "x": 4, + "y": 0, + "w": 1, + "h": 2, + }, + ], + "cols": 12, + "rowHeight": 30, + "width": 1200, + }, reactpy.html.div({"key": "a"}, "a"), reactpy.html.div({"key": "b"}, "b"), reactpy.html.div({"key": "c"}, "c"), ) ) - parent = await display.page.wait_for_selector(".react-grid-layout", state="attached") + parent = await display.page.wait_for_selector( + ".react-grid-layout", state="attached" + ) children = await parent.query_selector_all("div") # The children simply will not render unless they receive the key prop @@ -270,55 +273,52 @@ async def test_keys_properly_propagated(display: DisplayFixture): async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture): module = reactpy.web.module_from_file( - "subcomponent-notation", JS_FIXTURES_DIR / "subcomponent-notation.js", + "subcomponent-notation", + JS_FIXTURES_DIR / "subcomponent-notation.js", ) InputGroup, InputGroupText, FormControl, FormLabel = reactpy.web.export( - module, - ["InputGroup", "InputGroup.Text", "Form.Control", "Form.Label"] + module, ["InputGroup", "InputGroup.Text", "Form.Control", "Form.Label"] ) - content = reactpy.html.div({"id": "the-parent"}, + content = reactpy.html.div( + {"id": "the-parent"}, InputGroup( InputGroupText({"id": "basic-addon1"}, "@"), - FormControl({ - "placeholder": "Username", - "aria-label": "Username", - "aria-describedby": "basic-addon1", - }), + FormControl( + { + "placeholder": "Username", + "aria-label": "Username", + "aria-describedby": "basic-addon1", + } + ), ), - InputGroup( - FormControl({ - "placeholder": "Recipient's username", - "aria-label": "Recipient's username", - "aria-describedby": "basic-addon2", - }), + FormControl( + { + "placeholder": "Recipient's username", + "aria-label": "Recipient's username", + "aria-describedby": "basic-addon2", + } + ), InputGroupText({"id": "basic-addon2"}, "@example.com"), ), - FormLabel({"htmlFor": "basic-url"}, "Your vanity URL"), InputGroup( - InputGroupText({"id": "basic-addon3"}, - "https://example.com/users/" - ), + InputGroupText({"id": "basic-addon3"}, "https://example.com/users/"), FormControl({"id": "basic-url", "aria-describedby": "basic-addon3"}), ), - InputGroup( InputGroupText("$"), FormControl({"aria-label": "Amount (to the nearest dollar)"}), InputGroupText(".00"), ), - InputGroup( InputGroupText("With textarea"), FormControl({"as": "textarea", "aria-label": "With textarea"}), - ) + ), ) - await display.show( - lambda: content - ) + await display.show(lambda: content) parent = await display.page.wait_for_selector("#the-parent", state="visible") input_group_text = await parent.query_selector_all(".input-group-text") @@ -332,52 +332,50 @@ async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture): async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture): module = reactpy.web.module_from_file( - "subcomponent-notation", JS_FIXTURES_DIR / "subcomponent-notation.js", + "subcomponent-notation", + JS_FIXTURES_DIR / "subcomponent-notation.js", ) InputGroup, Form = reactpy.web.export(module, ["InputGroup", "Form"]) - content = reactpy.html.div({"id": "the-parent"}, + content = reactpy.html.div( + {"id": "the-parent"}, InputGroup( InputGroup.Text({"id": "basic-addon1"}, "@"), - Form.Control({ - "placeholder": "Username", - "aria-label": "Username", - "aria-describedby": "basic-addon1", - }), + Form.Control( + { + "placeholder": "Username", + "aria-label": "Username", + "aria-describedby": "basic-addon1", + } + ), ), - InputGroup( - Form.Control({ - "placeholder": "Recipient's username", - "aria-label": "Recipient's username", - "aria-describedby": "basic-addon2", - }), + Form.Control( + { + "placeholder": "Recipient's username", + "aria-label": "Recipient's username", + "aria-describedby": "basic-addon2", + } + ), InputGroup.Text({"id": "basic-addon2"}, "@example.com"), ), - Form.Label({"htmlFor": "basic-url"}, "Your vanity URL"), InputGroup( - InputGroup.Text({"id": "basic-addon3"}, - "https://example.com/users/" - ), + InputGroup.Text({"id": "basic-addon3"}, "https://example.com/users/"), Form.Control({"id": "basic-url", "aria-describedby": "basic-addon3"}), ), - InputGroup( InputGroup.Text("$"), Form.Control({"aria-label": "Amount (to the nearest dollar)"}), InputGroup.Text(".00"), ), - InputGroup( InputGroup.Text("With textarea"), Form.Control({"as": "textarea", "aria-label": "With textarea"}), - ) + ), ) - await display.show( - lambda: content - ) + await display.show(lambda: content) parent = await display.page.wait_for_selector("#the-parent", state="visible") input_group_text = await parent.query_selector_all(".input-group-text") From b3063bac2c17fd2f52fc29c1a7eb5ce475caefad Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 18 Mar 2025 21:00:14 -0600 Subject: [PATCH 06/13] Another linter fix --- src/js/packages/@reactpy/client/src/vdom.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index e97e487db..d6ccd8f0b 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -68,7 +68,7 @@ function createImportSourceElement(props: { }): any { let type: any; if (props.model.importSource) { - let rootType = props.model.tagName.split(".")[0]; + const rootType = props.model.tagName.split(".")[0]; if ( !isImportSourceEqual(props.currentImportSource, props.model.importSource) ) { @@ -107,13 +107,13 @@ function createImportSourceElement(props: { function tryGetSubType(module: ReactPyModule, component: string) { let subComponents: string[] = component.split("."); - let rootComponent: string = subComponents[0]; + const rootComponent: string = subComponents[0]; let subComponentAccessor: string = rootComponent; let type: any = module[rootComponent]; subComponents = subComponents.slice(1); for (let i = 0; i < subComponents.length; i++) { - let subComponent = subComponents[i]; + const subComponent = subComponents[i]; subComponentAccessor += "." + subComponent; type = type[subComponent]; if (!type) { From 11303f8d72a25641897889589e636112008eae66 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Wed, 19 Mar 2025 22:40:19 -0600 Subject: [PATCH 07/13] Addresses feedback by @Archmonger --- docs/source/about/changelog.rst | 1 + src/js/packages/@reactpy/client/src/vdom.tsx | 64 ++++++++++++-------- src/reactpy/core/vdom.py | 4 ++ tests/test_core/test_vdom.py | 9 +++ 4 files changed, 53 insertions(+), 25 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 0db168ba2..261d948c0 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -29,6 +29,7 @@ Unreleased - :pull:`1281` - ``reactpy.html`` will now automatically flatten lists recursively (ex. ``reactpy.html(["child1", ["child2"]])``) - :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 **Changed** diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index d6ccd8f0b..cae706787 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -68,7 +68,6 @@ function createImportSourceElement(props: { }): any { let type: any; if (props.model.importSource) { - const rootType = props.model.tagName.split(".")[0]; if ( !isImportSourceEqual(props.currentImportSource, props.model.importSource) ) { @@ -79,16 +78,16 @@ function createImportSourceElement(props: { stringifyImportSource(props.model.importSource), ); return null; - } else if (!props.module[rootType]) { - log.error( - "Module from source " + - stringifyImportSource(props.currentImportSource) + - ` does not export ${rootType}`, - ); - return null; } else { - type = tryGetSubType(props.module, props.model.tagName); - if (!type) return null; + type = getComponentFromModule( + props.module, + props.model.tagName, + props.model.importSource, + ); + if (!type) { + // Error message logged within getComponentFromModule + return null; + } } } else { type = props.model.tagName; @@ -105,25 +104,40 @@ function createImportSourceElement(props: { ); } -function tryGetSubType(module: ReactPyModule, component: string) { - let subComponents: string[] = component.split("."); - const rootComponent: string = subComponents[0]; - let subComponentAccessor: string = rootComponent; - let type: any = module[rootComponent]; +function getComponentFromModule( + module: ReactPyModule, + componentName: string, + importSource: ReactPyVdomImportSource, +): any { + /* Gets the component with the provided name from the provided module. - subComponents = subComponents.slice(1); - for (let i = 0; i < subComponents.length; i++) { - const subComponent = subComponents[i]; - subComponentAccessor += "." + subComponent; - type = type[subComponent]; - if (!type) { - console.error( - `Component ${rootComponent} does not have subcomponent ${subComponentAccessor}`, - ); + Built specifically to work on inifinitely deep nested components. + For example, component "My.Nested.Component" is accessed from + ModuleA like so: ModuleA["My"]["Nested"]["Component"]. + */ + const componentParts: string[] = componentName.split("."); + let Component: any = null; + for (let i = 0; i < componentParts.length; i++) { + const iterAttr = componentParts[i]; + Component = i == 0 ? module[iterAttr] : Component[iterAttr]; + if (!Component) { + if (i == 0) { + log.error( + "Module from source " + + stringifyImportSource(importSource) + + ` does not export ${iterAttr}`, + ); + } else { + console.error( + `Component ${componentParts.slice(0, i).join(".")} from source ` + + stringifyImportSource(importSource) + + ` does not have subcomponent ${iterAttr}`, + ); + } break; } } - return type; + return Component; } function isImportSourceEqual( diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index efd7fe67e..c2cbe7d4e 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -136,6 +136,10 @@ def __init__( self.__qualname__ = f"{module_name}.{tag_name}" def __getattr__(self, attr: str) -> Vdom: + """Supports accessing nested web module components""" + if not self.import_source: + msg = "Nested comopnents can only be accessed on web module components." + raise AttributeError(msg) return Vdom( f"{self.__name__}.{attr}", allow_children=self.allow_children, diff --git a/tests/test_core/test_vdom.py b/tests/test_core/test_vdom.py index dbf0bbcd6..68d27e6fa 100644 --- a/tests/test_core/test_vdom.py +++ b/tests/test_core/test_vdom.py @@ -123,6 +123,15 @@ def test_make_vdom_constructor(): assert no_children() == {"tagName": "no-children"} +def test_nested_html_access_raises_error(): + elmt = Vdom("div") + + with pytest.raises( + AttributeError, match="can only be accessed on web module components" + ): + elmt.fails() + + @pytest.mark.parametrize( "value", [ From 8f4bfccd55f881754b9efc7102978dc23525f0b4 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Wed, 19 Mar 2025 23:05:46 -0600 Subject: [PATCH 08/13] Fixes typo --- src/reactpy/core/vdom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index c2cbe7d4e..7ecddcf0e 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -138,7 +138,7 @@ def __init__( def __getattr__(self, attr: str) -> Vdom: """Supports accessing nested web module components""" if not self.import_source: - msg = "Nested comopnents can only be accessed on web module components." + msg = "Nested components can only be accessed on web module components." raise AttributeError(msg) return Vdom( f"{self.__name__}.{attr}", From 25d19213e6b0a27e680aed01aabb30eec48072f8 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Sat, 22 Mar 2025 07:41:51 -0600 Subject: [PATCH 09/13] Swap unpkg for esm to fix failing tests --- .../_examples/super_simple_chart/super-simple-chart.js | 4 ++-- tests/test_web/js_fixtures/component-can-have-child.js | 4 ++-- tests/test_web/js_fixtures/exports-two-components.js | 4 ++-- tests/test_web/js_fixtures/simple-button.js | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js b/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js index 486e5c363..c5dc093de 100644 --- a/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js +++ b/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js @@ -1,5 +1,5 @@ -import { h, render } from "https://unpkg.com/preact?module"; -import htm from "https://unpkg.com/htm?module"; +import { h, render } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; const html = htm.bind(h); diff --git a/tests/test_web/js_fixtures/component-can-have-child.js b/tests/test_web/js_fixtures/component-can-have-child.js index fd443b164..20ab12aef 100644 --- a/tests/test_web/js_fixtures/component-can-have-child.js +++ b/tests/test_web/js_fixtures/component-can-have-child.js @@ -1,5 +1,5 @@ -import { h, render } from "https://unpkg.com/preact?module"; -import htm from "https://unpkg.com/htm?module"; +import { h, render } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; const html = htm.bind(h); diff --git a/tests/test_web/js_fixtures/exports-two-components.js b/tests/test_web/js_fixtures/exports-two-components.js index 10aa7fdbe..e99466df2 100644 --- a/tests/test_web/js_fixtures/exports-two-components.js +++ b/tests/test_web/js_fixtures/exports-two-components.js @@ -1,5 +1,5 @@ -import { h, render } from "https://unpkg.com/preact?module"; -import htm from "https://unpkg.com/htm?module"; +import { h, render } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; const html = htm.bind(h); diff --git a/tests/test_web/js_fixtures/simple-button.js b/tests/test_web/js_fixtures/simple-button.js index 2b49f505b..418b1c4ab 100644 --- a/tests/test_web/js_fixtures/simple-button.js +++ b/tests/test_web/js_fixtures/simple-button.js @@ -1,5 +1,5 @@ -import { h, render } from "https://unpkg.com/preact?module"; -import htm from "https://unpkg.com/htm?module"; +import { h, render } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; const html = htm.bind(h); From bddc027217119af245afd40205ed83ce44ee8aac Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 25 Mar 2025 22:13:37 -0600 Subject: [PATCH 10/13] Revert last commit - restore unpkg --- .../_examples/super_simple_chart/super-simple-chart.js | 4 ++-- tests/test_web/js_fixtures/component-can-have-child.js | 4 ++-- tests/test_web/js_fixtures/exports-two-components.js | 4 ++-- tests/test_web/js_fixtures/simple-button.js | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js b/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js index c5dc093de..486e5c363 100644 --- a/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js +++ b/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js @@ -1,5 +1,5 @@ -import { h, render } from "https://esm.sh/preact"; -import htm from "https://esm.sh/htm"; +import { h, render } from "https://unpkg.com/preact?module"; +import htm from "https://unpkg.com/htm?module"; const html = htm.bind(h); diff --git a/tests/test_web/js_fixtures/component-can-have-child.js b/tests/test_web/js_fixtures/component-can-have-child.js index 20ab12aef..fd443b164 100644 --- a/tests/test_web/js_fixtures/component-can-have-child.js +++ b/tests/test_web/js_fixtures/component-can-have-child.js @@ -1,5 +1,5 @@ -import { h, render } from "https://esm.sh/preact"; -import htm from "https://esm.sh/htm"; +import { h, render } from "https://unpkg.com/preact?module"; +import htm from "https://unpkg.com/htm?module"; const html = htm.bind(h); diff --git a/tests/test_web/js_fixtures/exports-two-components.js b/tests/test_web/js_fixtures/exports-two-components.js index e99466df2..10aa7fdbe 100644 --- a/tests/test_web/js_fixtures/exports-two-components.js +++ b/tests/test_web/js_fixtures/exports-two-components.js @@ -1,5 +1,5 @@ -import { h, render } from "https://esm.sh/preact"; -import htm from "https://esm.sh/htm"; +import { h, render } from "https://unpkg.com/preact?module"; +import htm from "https://unpkg.com/htm?module"; const html = htm.bind(h); diff --git a/tests/test_web/js_fixtures/simple-button.js b/tests/test_web/js_fixtures/simple-button.js index 418b1c4ab..2b49f505b 100644 --- a/tests/test_web/js_fixtures/simple-button.js +++ b/tests/test_web/js_fixtures/simple-button.js @@ -1,5 +1,5 @@ -import { h, render } from "https://esm.sh/preact"; -import htm from "https://esm.sh/htm"; +import { h, render } from "https://unpkg.com/preact?module"; +import htm from "https://unpkg.com/htm?module"; const html = htm.bind(h); From 1c6fe434bc0d5c91916617a7cbe967962df839e0 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 25 Mar 2025 22:25:10 -0600 Subject: [PATCH 11/13] Tweak test to wait for attached, not visible --- tests/test_web/test_module.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 49cbe476e..ae6967a28 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -320,7 +320,7 @@ async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture): await display.show(lambda: content) - parent = await display.page.wait_for_selector("#the-parent", state="visible") + parent = await display.page.wait_for_selector("#the-parent", state="attached") input_group_text = await parent.query_selector_all(".input-group-text") form_control = await parent.query_selector_all(".form-control") form_label = await parent.query_selector_all(".form-label") @@ -377,7 +377,7 @@ async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture): await display.show(lambda: content) - parent = await display.page.wait_for_selector("#the-parent", state="visible") + parent = await display.page.wait_for_selector("#the-parent", state="attached") input_group_text = await parent.query_selector_all(".input-group-text") form_control = await parent.query_selector_all(".form-control") form_label = await parent.query_selector_all(".form-label") From 9dd55cf02408679d8ed4600d140b65785ff381ed Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 25 Mar 2025 22:40:17 -0600 Subject: [PATCH 12/13] Fixes failing tests --- tests/test_web/test_module.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index ae6967a28..9594be4ae 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -320,6 +320,7 @@ async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture): await display.show(lambda: content) + await display.page.wait_for_selector("#basic-addon3", state="attached") parent = await display.page.wait_for_selector("#the-parent", state="attached") input_group_text = await parent.query_selector_all(".input-group-text") form_control = await parent.query_selector_all(".form-control") @@ -377,6 +378,7 @@ async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture): await display.show(lambda: content) + await display.page.wait_for_selector("#basic-addon3", state="attached") parent = await display.page.wait_for_selector("#the-parent", state="attached") input_group_text = await parent.query_selector_all(".input-group-text") form_control = await parent.query_selector_all(".form-control") From efb9f88f6382c5828a752d7535adf23c3485f40b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 26 Mar 2025 01:50:48 -0700 Subject: [PATCH 13/13] Temporarily disable JS tests --- .github/workflows/check.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 86a457136..4a8e58774 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -37,6 +37,9 @@ jobs: run-cmd: "hatch run docs:check" python-version: '["3.11"]' test-javascript: + # Temporarily disabled, tests are broken but a rewrite is intended + # https://github.com/reactive-python/reactpy/issues/1196 + if: 0 uses: ./.github/workflows/.hatch-run.yml with: job-name: "{1}"