Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/source/about/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
53 changes: 45 additions & 8 deletions src/js/packages/@reactpy/client/src/vdom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/reactpy/core/vdom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/reactpy/web/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 12 additions & 3 deletions tests/test_core/test_vdom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]},
),
(
Expand Down Expand Up @@ -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",
[
Expand Down Expand Up @@ -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]
Expand Down
6 changes: 5 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,11 @@ def test_string_to_reactpy(case):
# 8: Infer ReactJS `key` from the `key` attribute
{
"source": '<div key="my-key"></div>',
"model": {"tagName": "div", "attributes": {"key": "my-key"}, "key": "my-key"},
"model": {
"tagName": "div",
"attributes": {"key": "my-key"},
"key": "my-key",
},
},
],
)
Expand Down
14 changes: 14 additions & 0 deletions tests/test_web/js_fixtures/subcomponent-notation.js
Original file line number Diff line number Diff line change
@@ -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()
};
}
187 changes: 153 additions & 34 deletions tests/test_web/test_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -224,50 +224,169 @@ 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)

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():
reactpy.web.module_from_string("temp", "old")
with assert_reactpy_did_log(r"Existing web module .* will be replaced with"):
Expand Down
Loading