Skip to content

Commit 039766b

Browse files
Fix: Include child component JavaScript calls in response
1 parent 2368bad commit 039766b

File tree

5 files changed

+152
-4
lines changed

5 files changed

+152
-4
lines changed

src/django_unicorn/components/unicorn_view.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def construct_component(
147147
)
148148

149149
component.calls = []
150-
component.children = []
150+
151151

152152
component._mount_result = component.mount()
153153
component.hydrate()

src/django_unicorn/views/response.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,38 @@ def __init__(
2323
self.return_data = return_data
2424
self.partials = partials or []
2525

26+
def _collect_all_calls(self) -> list[dict[str, Any]]:
27+
"""
28+
Collect JavaScript calls from this component and all its children recursively.
29+
30+
Returns:
31+
List of call dictionaries with 'fn' and 'args' keys.
32+
"""
33+
all_calls = list(self.component.calls) # Start with parent's calls
34+
35+
# Recursively collect from all children
36+
all_calls.extend(self._collect_calls_from_component(self.component))
37+
38+
return all_calls
39+
40+
def _collect_calls_from_component(self, component: UnicornView) -> list[dict[str, Any]]:
41+
"""
42+
Helper to recursively collect calls from a component's descendants.
43+
44+
Args:
45+
component: The component to collect calls from.
46+
47+
Returns:
48+
List of call dictionaries from all descendants.
49+
"""
50+
calls = []
51+
for child in component.children:
52+
# Add this child's calls
53+
calls.extend(child.calls)
54+
# Recursively collect from this child's descendants
55+
calls.extend(self._collect_calls_from_component(child))
56+
return calls
57+
2658
def get_data(self) -> dict[str, Any]:
2759
# Sort data so it's stable
2860
if self.component_request.data:
@@ -34,7 +66,7 @@ def get_data(self) -> dict[str, Any]:
3466
"id": self.component_request.id,
3567
"data": self.component_request.data,
3668
"errors": self.component.errors,
37-
"calls": self.component.calls,
69+
"calls": self._collect_all_calls(),
3870
"checksum": generate_checksum(self.component_request.data),
3971
}
4072

@@ -50,7 +82,7 @@ def get_data(self) -> dict[str, Any]:
5082
if (
5183
self.component_request.hash == rendered_component_hash
5284
and (not self.return_data or not self.return_data.value)
53-
and not self.component.calls
85+
and not self._collect_all_calls()
5486
):
5587
if not self.component.parent and self.component.force_render is False:
5688
raise RenderNotModifiedError()

tests/views/message/test_calls.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,117 @@ def test_message_calls_with_arg(client):
6666
response = post_and_get_response(client, url=FAKE_CALLS_COMPONENT_URL, action_queue=action_queue)
6767

6868
assert response.get("calls") == [{"args": ["hello"], "fn": "testCall3"}]
69+
70+
71+
class FakeChildComponent(UnicornView):
72+
template_name = "templates/test_component.html"
73+
74+
def child_method(self):
75+
self.call("createChart", {"data": "test"})
76+
77+
78+
class FakeParentComponent(UnicornView):
79+
template_name = "templates/test_component.html"
80+
81+
def __init__(self, **kwargs):
82+
super().__init__(**kwargs)
83+
# Create a child component
84+
child = FakeChildComponent(component_name="fake-child", id="child-123")
85+
child.parent = self
86+
self.children.append(child)
87+
88+
def call_child_method(self):
89+
# Parent calls child's method
90+
for child in self.children:
91+
if hasattr(child, "child_method") and callable(child.child_method):
92+
child.child_method() # type: ignore[call-top-callable]
93+
94+
95+
FAKE_PARENT_COMPONENT_URL = "/message/tests.views.message.test_calls.FakeParentComponent"
96+
97+
98+
def test_message_child_calls_from_parent(client):
99+
"""Test that child component calls are included when parent calls child method."""
100+
action_queue = [
101+
{
102+
"payload": {"name": "call_child_method"},
103+
"type": "callMethod",
104+
"target": None,
105+
}
106+
]
107+
108+
response = post_and_get_response(client, url=FAKE_PARENT_COMPONENT_URL, action_queue=action_queue)
109+
110+
# Verify child's JavaScript call is in the response
111+
calls = response.get("calls", [])
112+
assert any(call["fn"] == "createChart" for call in calls), f"Expected 'createChart' in calls, got: {calls}"
113+
assert len(calls) == 1
114+
assert calls[0]["args"] == [{"data": "test"}]
115+
116+
117+
class FakeGrandchildComponent(UnicornView):
118+
template_name = "templates/test_component.html"
119+
120+
def grandchild_method(self):
121+
self.call("grandchildFunction", "nested")
122+
123+
124+
class FakeChildWithGrandchildComponent(UnicornView):
125+
template_name = "templates/test_component.html"
126+
127+
def __init__(self, **kwargs):
128+
super().__init__(**kwargs)
129+
# Create a grandchild component
130+
grandchild = FakeGrandchildComponent(component_name="fake-grandchild", id="grandchild-456")
131+
grandchild.parent = self
132+
self.children.append(grandchild)
133+
134+
def child_method_with_call(self):
135+
self.call("childFunction")
136+
# Also call grandchild's method
137+
for child in self.children:
138+
if hasattr(child, "grandchild_method") and callable(child.grandchild_method):
139+
child.grandchild_method() # type: ignore[call-top-callable]
140+
141+
142+
class FakeParentWithNestedChildren(UnicornView):
143+
template_name = "templates/test_component.html"
144+
145+
def __init__(self, **kwargs):
146+
super().__init__(**kwargs)
147+
# Create a child component with its own child
148+
child = FakeChildWithGrandchildComponent(component_name="fake-child-nested", id="child-nested-789")
149+
child.parent = self
150+
self.children.append(child)
151+
152+
def call_nested_children(self):
153+
self.call("parentFunction")
154+
# Call child's method which will also call grandchild
155+
for child in self.children:
156+
if hasattr(child, "child_method_with_call") and callable(child.child_method_with_call):
157+
child.child_method_with_call() # type: ignore[call-top-callable]
158+
159+
160+
FAKE_NESTED_COMPONENT_URL = "/message/tests.views.message.test_calls.FakeParentWithNestedChildren"
161+
162+
163+
def test_message_nested_child_calls(client):
164+
"""Test that grandchild component calls are included in deeply nested scenarios."""
165+
action_queue = [
166+
{
167+
"payload": {"name": "call_nested_children"},
168+
"type": "callMethod",
169+
"target": None,
170+
}
171+
]
172+
173+
response = post_and_get_response(client, url=FAKE_NESTED_COMPONENT_URL, action_queue=action_queue)
174+
175+
# Verify all levels of calls are collected: parent, child, and grandchild
176+
calls = response.get("calls", [])
177+
call_functions = [call["fn"] for call in calls]
178+
179+
assert "parentFunction" in call_functions, f"Expected 'parentFunction' in calls, got: {call_functions}"
180+
assert "childFunction" in call_functions, f"Expected 'childFunction' in calls, got: {call_functions}"
181+
assert "grandchildFunction" in call_functions, f"Expected 'grandchildFunction' in calls, got: {call_functions}"
182+
assert len(calls) == 3

tests/views/test_m2m_overwriting.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ class M2MComponent(UnicornView):
1111

1212
def mount(self):
1313
if self.component_args:
14-
self.taste = Taste.objects.get(pk=self.component_args[0])
14+
self.taste = Taste.objects.get(pk=self.component_args[0])
1515

1616
def refresh(self):
1717
# usage: unicorn:poll="refresh"
1818
if self.taste:
1919
self.taste = Taste.objects.get(pk=self.taste.pk)
2020

21+
2122
@pytest.mark.django_db
2223
def test_m2m_overwriting(client):
2324
# Setup

tests/views/test_unit_views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def test_component_response_structure():
8686
component = Mock(spec=Component)
8787
component.errors = {}
8888
component.calls = []
89+
component.children = []
8990
component.parent = None
9091
component.force_render = False
9192

0 commit comments

Comments
 (0)