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}" 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 25eb9f3e7..cae706787 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -78,15 +78,16 @@ function createImportSourceElement(props: { stringifyImportSource(props.model.importSource), ); return null; - } else if (!props.module[props.model.tagName]) { - log.error( - "Module from source " + - stringifyImportSource(props.currentImportSource) + - ` does not export ${props.model.tagName}`, - ); - return null; } else { - type = props.module[props.model.tagName]; + type = getComponentFromModule( + props.module, + props.model.tagName, + props.model.importSource, + ); + if (!type) { + // Error message logged within getComponentFromModule + return null; + } } } else { type = props.model.tagName; @@ -103,6 +104,42 @@ function createImportSourceElement(props: { ); } +function getComponentFromModule( + module: ReactPyModule, + componentName: string, + importSource: ReactPyVdomImportSource, +): any { + /* Gets the component with the provided name from the provided module. + + 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 Component; +} + function isImportSourceEqual( source1: ReactPyVdomImportSource, source2: ReactPyVdomImportSource, diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 6bc28dfd4..7ecddcf0e 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -135,6 +135,17 @@ def __init__( self.__module__ = module_name 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 components can only be accessed on web module components." + raise AttributeError(msg) + 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/src/reactpy/web/module.py b/src/reactpy/web/module.py index 04c898338..bd35f92cb 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -260,14 +260,18 @@ 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( + {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..68d27e6fa 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]}, ), ( @@ -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", [ @@ -293,7 +302,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/js_fixtures/subcomponent-notation.js b/tests/test_web/js_fixtures/subcomponent-notation.js new file mode 100644 index 000000000..73527c667 --- /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 {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), + 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..9594be4ae 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( @@ -224,50 +224,171 @@ 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 assert len(children) == 3 +async def test_subcomponent_notation_as_str_attrs(display: DisplayFixture): + module = reactpy.web.module_from_file( + "subcomponent-notation", + JS_FIXTURES_DIR / "subcomponent-notation.js", + ) + 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: 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") + 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) + + 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") + 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(): reactpy.web.module_from_string("temp", "old") with assert_reactpy_did_log(r"Existing web module .* will be replaced with"):