Skip to content

Commit a541532

Browse files
committed
Saner check for the magic setter method. Make toggle work with nested properties using dot-notation.
1 parent 55d47c1 commit a541532

File tree

6 files changed

+185
-72
lines changed

6 files changed

+185
-72
lines changed

django_unicorn/call_method_parser.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ def parse_kwarg(kwarg: str, raise_if_unparseable=False) -> Dict[str, Any]:
5555
f"{kwarg} key cannot contain a single quote or double quote"
5656
)
5757

58-
# Attempt to parse the value into a primitive, but return it un-parsed if not possible
59-
# because the value can be a template variable that will get set from the context when
60-
# the templatetag is rendered
58+
# Attempt to parse the value into a primitive, but allow it to be returned if not possible
59+
# (the value can be a template variable that will get set from the context when
60+
# the templatetag is rendered in which case it can't be parsed in this manner)
6161
try:
6262
val = parse_args(val)[0]
6363
except ValueError:

django_unicorn/views.py

Lines changed: 106 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99

1010
import orjson
1111

12-
from .call_method_parser import parse_args, parse_call_method_name
12+
from .call_method_parser import (
13+
InvalidKwarg,
14+
parse_args,
15+
parse_call_method_name,
16+
parse_kwarg,
17+
)
1318
from .components import UnicornField, UnicornView
1419
from .errors import UnicornViewError
1520
from .utils import generate_checksum
@@ -58,7 +63,7 @@ def _set_property_from_data(
5863

5964

6065
def _set_property_from_payload(
61-
component: UnicornView, payload: Dict, data: Dict
66+
component: UnicornView, payload: Dict, data: Dict = {}
6267
) -> None:
6368
"""
6469
Sets properties on the component based on the payload.
@@ -67,68 +72,97 @@ def _set_property_from_payload(
6772
Args:
6873
param component: Component to set attributes on.
6974
param payload: Dictionary that comes with request.
70-
param data: Dictionary that gets sent back with the response.
75+
param data: Dictionary that gets sent back with the response. Defaults to {}.
7176
"""
7277

7378
property_name = payload.get("name")
79+
assert property_name is not None, "Payload name is required"
7480
property_value = payload.get("value")
81+
assert property_value is not None, "Payload value is required"
82+
7583
component.updating(property_name, property_value)
7684

77-
if property_name is not None and property_value is not None:
78-
"""
79-
Handles nested properties. For example, for the following component:
85+
"""
86+
Handles nested properties. For example, for the following component:
8087
81-
class Author(UnicornField):
82-
name = "Neil"
88+
class Author(UnicornField):
89+
name = "Neil"
8390
84-
class TestView(UnicornView):
85-
author = Author()
86-
87-
`payload` would be `{'name': 'author.name', 'value': 'Neil Gaiman'}`
91+
class TestView(UnicornView):
92+
author = Author()
93+
94+
`payload` would be `{'name': 'author.name', 'value': 'Neil Gaiman'}`
8895
89-
The following code updates UnicornView.author.name based the payload's `author.name`.
90-
"""
91-
property_name_parts = property_name.split(".")
92-
component_or_field = component
93-
data_or_dict = data # Could be an internal portion of data that gets set
94-
95-
for (idx, property_name_part) in enumerate(property_name_parts):
96-
if hasattr(component_or_field, property_name_part):
97-
if idx == len(property_name_parts) - 1:
98-
if hasattr(component_or_field, "_set_property"):
99-
# Can assume that `component_or_field` is a component
100-
component_or_field._set_property(
101-
property_name_part, property_value
102-
)
103-
else:
104-
# Handle calling the updating/updated method for nested properties
105-
property_name_snake_case = property_name.replace(".", "_")
106-
updating_function_name = f"updating_{property_name_snake_case}"
107-
updated_function_name = f"updated_{property_name_snake_case}"
108-
109-
if hasattr(component, updating_function_name):
110-
getattr(component, updating_function_name)(property_value)
111-
112-
setattr(component_or_field, property_name_part, property_value)
113-
114-
if hasattr(component, updated_function_name):
115-
getattr(component, updated_function_name)(property_value)
116-
117-
data_or_dict[property_name_part] = property_value
118-
else:
119-
component_or_field = getattr(component_or_field, property_name_part)
120-
data_or_dict = data_or_dict.get(property_name_part, {})
121-
elif isinstance(component_or_field, dict):
122-
if idx == len(property_name_parts) - 1:
123-
component_or_field[property_name_part] = property_value
124-
data_or_dict[property_name_part] = property_value
96+
The following code updates UnicornView.author.name based the payload's `author.name`.
97+
"""
98+
property_name_parts = property_name.split(".")
99+
component_or_field = component
100+
data_or_dict = data # Could be an internal portion of data that gets set
101+
102+
for (idx, property_name_part) in enumerate(property_name_parts):
103+
if hasattr(component_or_field, property_name_part):
104+
if idx == len(property_name_parts) - 1:
105+
if hasattr(component_or_field, "_set_property"):
106+
# Can assume that `component_or_field` is a component
107+
component_or_field._set_property(property_name_part, property_value)
125108
else:
126-
component_or_field = component_or_field[property_name_part]
127-
data_or_dict = data_or_dict.get(property_name_part, {})
109+
# Handle calling the updating/updated method for nested properties
110+
property_name_snake_case = property_name.replace(".", "_")
111+
updating_function_name = f"updating_{property_name_snake_case}"
112+
updated_function_name = f"updated_{property_name_snake_case}"
113+
114+
if hasattr(component, updating_function_name):
115+
getattr(component, updating_function_name)(property_value)
116+
117+
setattr(component_or_field, property_name_part, property_value)
118+
119+
if hasattr(component, updated_function_name):
120+
getattr(component, updated_function_name)(property_value)
121+
122+
data_or_dict[property_name_part] = property_value
123+
else:
124+
component_or_field = getattr(component_or_field, property_name_part)
125+
data_or_dict = data_or_dict.get(property_name_part, {})
126+
elif isinstance(component_or_field, dict):
127+
if idx == len(property_name_parts) - 1:
128+
component_or_field[property_name_part] = property_value
129+
data_or_dict[property_name_part] = property_value
130+
else:
131+
component_or_field = component_or_field[property_name_part]
132+
data_or_dict = data_or_dict.get(property_name_part, {})
128133

129134
component.updated(property_name, property_value)
130135

131136

137+
def _get_property_value(component: UnicornView, property_name: str) -> Any:
138+
"""
139+
Gets property value from the component based on the property name.
140+
Handles nested property names.
141+
142+
Args:
143+
param component: Component to get property values from.
144+
param property_name: Property name. Can be "dot-notation" to get nested properties.
145+
"""
146+
147+
assert property_name is not None, "property_name name is required"
148+
149+
# Handles nested properties
150+
property_name_parts = property_name.split(".")
151+
component_or_field = component
152+
153+
for (idx, property_name_part) in enumerate(property_name_parts):
154+
if hasattr(component_or_field, property_name_part):
155+
if idx == len(property_name_parts) - 1:
156+
return getattr(component_or_field, property_name_part)
157+
else:
158+
component_or_field = getattr(component_or_field, property_name_part)
159+
elif isinstance(component_or_field, dict):
160+
if idx == len(property_name_parts) - 1:
161+
return component_or_field[property_name_part]
162+
else:
163+
component_or_field = component_or_field[property_name_part]
164+
165+
132166
def _call_method_name(
133167
component: UnicornView, method_name: str, params: List[Any]
134168
) -> None:
@@ -291,19 +325,24 @@ def message(request: HttpRequest, component_name: str = None) -> JsonResponse:
291325
call_method_name = payload.get("name", "")
292326
assert call_method_name, "Missing 'name' key for callMethod"
293327

328+
(method_name, params) = parse_call_method_name(call_method_name)
329+
setter_method = {}
330+
294331
if "=" in call_method_name:
295-
# TODO: Parse this in a sane way so that an equal sign
296-
# in the middle of a string doesn't trigger the set shortcut
297-
equal_sign_idx = call_method_name.index("=")
298-
property_name = call_method_name[:equal_sign_idx]
299-
parsed_args = parse_args(call_method_name[equal_sign_idx + 1 :])
300-
property_value = parsed_args[0] if parsed_args else None
301-
302-
if hasattr(component, property_name) and property_value is not None:
303-
component.calling(f"set_{property_name}", property_value)
304-
setattr(component, property_name, property_value)
305-
component.called(f"set_{property_name}", property_value)
306-
component_request.data[property_name] = property_value
332+
try:
333+
setter_method = parse_kwarg(
334+
call_method_name, raise_if_unparseable=True
335+
)
336+
except InvalidKwarg:
337+
pass
338+
339+
if setter_method:
340+
# Create a fake "payload" so that nested properties will get set as expected
341+
property_name = list(setter_method.keys())[0]
342+
property_value = setter_method[property_name]
343+
payload = {"name": property_name, "value": property_value}
344+
345+
_set_property_from_payload(component, payload)
307346
else:
308347
(method_name, params) = parse_call_method_name(call_method_name)
309348

@@ -329,12 +368,12 @@ def message(request: HttpRequest, component_name: str = None) -> JsonResponse:
329368
is_reset_called = True
330369
elif method_name == "toggle":
331370
for property_name in params:
332-
if hasattr(component, property_name):
333-
property_value = getattr(component, property_name)
371+
# Create a fake "payload" so that nested properties will get set as expected
372+
property_value = _get_property_value(component, property_name)
373+
property_value = not property_value
374+
payload = {"name": property_name, "value": property_value}
334375

335-
if isinstance(property_value, bool):
336-
property_value = not property_value
337-
setattr(component, property_name, property_value)
376+
_set_property_from_payload(component, payload)
338377
elif method_name == "validate":
339378
# Handle the validate special action
340379
validate_all_fields = True

example/unicorn/templates/unicorn/validation.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
<button unicorn:click="set_text_with_validation">set_text_with_validation()</button>
5151
<button unicorn:click="validate">validate()</button>
5252
<button unicorn:click="reset">reset()</button>
53+
<button unicorn:click="reset()">reset() 2</button>
5354
<button unicorn:click="refresh">refresh()</button>
5455
</div>
5556
</div>

tests/views/fake_components.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ class FakeComponent(UnicornView):
1010
template_name = "templates/test_component.html"
1111
dictionary = {"name": "test"}
1212
method_count = 0
13-
check = True
13+
check = False
14+
nested = {"check": False}
1415

1516
def test_method(self):
1617
self.method_count += 1

tests/views/message/test_call_method.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,28 @@ def test_message_call_method_setter(client):
4343
assert body["data"].get("method_count") == 2
4444

4545

46+
def test_message_call_method_nested_setter(client):
47+
data = {}
48+
message = {
49+
"actionQueue": [
50+
{"payload": {"name": "nested.check=False"}, "type": "callMethod",}
51+
],
52+
"data": data,
53+
"checksum": generate_checksum(orjson.dumps(data)),
54+
"id": "FDHcbzGf",
55+
}
56+
57+
response = client.post(
58+
"/message/tests.views.fake_components.FakeComponent",
59+
message,
60+
content_type="application/json",
61+
)
62+
63+
body = orjson.loads(response.content)
64+
65+
assert body["data"].get("nested").get("check") == False
66+
67+
4668
def test_message_call_method_toggle(client):
4769
data = {}
4870
message = {
@@ -62,7 +84,29 @@ def test_message_call_method_toggle(client):
6284

6385
body = orjson.loads(response.content)
6486

65-
assert body["data"].get("check") == False
87+
assert body["data"].get("check") == True
88+
89+
90+
def test_message_call_method_nested_toggle(client):
91+
data = {}
92+
message = {
93+
"actionQueue": [
94+
{"payload": {"name": "toggle('nested.check')"}, "type": "callMethod",}
95+
],
96+
"data": data,
97+
"checksum": generate_checksum(orjson.dumps(data)),
98+
"id": "FDHcbzGf",
99+
}
100+
101+
response = client.post(
102+
"/message/tests.views.fake_components.FakeComponent",
103+
message,
104+
content_type="application/json",
105+
)
106+
107+
body = orjson.loads(response.content)
108+
109+
assert body["data"].get("nested").get("check") == True
66110

67111

68112
def test_message_call_method_params(client):
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from django_unicorn.views import _get_property_value
2+
from tests.views.fake_components import FakeComponent
3+
4+
5+
def test_get_property_value(client):
6+
component = FakeComponent(component_name="test", component_id="asdf")
7+
8+
component.check = False
9+
check_value = _get_property_value(component, "check")
10+
assert check_value is False
11+
12+
component.check = True
13+
check_value = _get_property_value(component, "check")
14+
15+
assert check_value is True
16+
17+
18+
def test_get_property_value_nested(client):
19+
component = FakeComponent(component_name="test", component_id="asdf")
20+
21+
component.nested["check"] = False
22+
check_value = _get_property_value(component, "nested.check")
23+
assert check_value is False
24+
25+
component.nested["check"] = True
26+
check_value = _get_property_value(component, "nested.check")
27+
28+
assert check_value is True

0 commit comments

Comments
 (0)