Skip to content

Commit e6dea0b

Browse files
Add quoted vs unquoted value resolution for components (#77)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 46c7de0 commit e6dea0b

File tree

6 files changed

+368
-50
lines changed

6 files changed

+368
-50
lines changed

CHANGELOG.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
1818

1919
## [Unreleased]
2020

21+
### Changed
22+
23+
- Improved handling of quoted vs unquoted attribute values in `{% bird %}` components. Quoted values (e.g., `class="static-class"`) are treated as literal strings, while unquoted values (e.g., `class=dynamic_class`) are resolved from the template context. This allows for more explicit control over whether an attribute value should be treated as a literal or resolved dynamically.
24+
2125
## [0.6.2]
2226

2327
### Changed
@@ -53,7 +57,7 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
5357
### Added
5458

5559
- Added component caching with LRU (Least Recently Used) strategy via global `components` registry.
56-
- `cachetools>=5.5.0` is now a dependency of the library to support this new cache strategy
60+
- `cachetools>=5.5.0` is now a dependency of the library to support this new cache strategy
5761

5862
### Changed
5963

@@ -112,8 +116,8 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
112116
- Created a custom template loader for integration with django-bird's compiler and Django's template engine.
113117
- Added support for nested components and dynamic slot rendering.
114118
- Initial configuration of the library through the `settings.DJANGO_BIRD` dictionary, including these settings:
115-
- `COMPONENT_DIRS` - List of directories to search for components
116-
- `ENABLE_AUTO_CONFIG` - Boolean to enable/disable auto-configuration
119+
- `COMPONENT_DIRS` - List of directories to search for components
120+
- `ENABLE_AUTO_CONFIG` - Boolean to enable/disable auto-configuration
117121

118122
### New Contributors
119123

docs/params.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,85 @@ This will render as:
184184
```{note}
185185
Props defined without a default value will render as an empty string if no value is provided when using the component. This behavior may change in a future version to either require default values or handle undefined props differently.
186186
```
187+
188+
## Value Resolution
189+
190+
Both attributes and properties support literal (quoted) and dynamic (unquoted) values. This allows you to either hard-code values or resolve them from the template context.
191+
192+
The rules for value resolution are:
193+
194+
- Quoted values (`"value"` or `'value'`) are treated as literal strings
195+
- Unquoted values are resolved from the template context
196+
- Boolean values can be passed directly (`disabled=True`) or as strings (`disabled="True"`)
197+
- Both attributes and properties follow these same resolution rules
198+
199+
### Literal Values
200+
201+
Using quoted values ensures the exact string is used:
202+
203+
```htmldjango
204+
{% bird button class="btn-primary" variant="large" disabled="true" %}
205+
Click me
206+
{% endbird %}
207+
```
208+
209+
Renders as:
210+
211+
```html
212+
<button class="btn-primary" variant="large" disabled="true">Click me</button>
213+
```
214+
215+
### Dynamic Values
216+
217+
Unquoted values are resolved from the template context:
218+
219+
```htmldjango
220+
{% bird button class=button_class variant=size disabled=is_disabled %}
221+
Click me
222+
{% endbird %}
223+
```
224+
225+
With this context:
226+
227+
```python
228+
{
229+
"button_class": "btn-secondary",
230+
"size": "small",
231+
"is_disabled": True,
232+
}
233+
```
234+
235+
Renders as:
236+
237+
```html
238+
<button class="btn-secondary" variant="small" disabled>Click me</button>
239+
```
240+
241+
You can also access nested attributes using dot notation:
242+
243+
```htmldjango
244+
{% bird button class=theme.button.class disabled=user.is_inactive %}
245+
Click me
246+
{% endbird %}
247+
```
248+
249+
With this context:
250+
251+
```python
252+
{
253+
"theme": {
254+
"button": {
255+
"class": "themed-button",
256+
}
257+
},
258+
"user": {
259+
"is_inactive": True,
260+
},
261+
}
262+
```
263+
264+
Renders as:
265+
266+
```html
267+
<button class="themed-button" disabled>Click me</button>
268+
```

src/django_bird/params.py

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from dataclasses import dataclass
44
from dataclasses import field
5+
from typing import Any
56

67
from django import template
78
from django.template.base import NodeList
@@ -12,34 +13,56 @@
1213
from ._typing import TagBits
1314

1415

16+
@dataclass
17+
class Value:
18+
raw: str | bool | None
19+
quoted: bool = False
20+
21+
def resolve(self, context: Context) -> Any:
22+
if self.raw is None or (isinstance(self.raw, str) and self.raw == "False"):
23+
return None
24+
if (isinstance(self.raw, bool) and self.raw) or (
25+
isinstance(self.raw, str) and self.raw == "True"
26+
):
27+
return True
28+
if isinstance(self.raw, str) and not self.quoted:
29+
try:
30+
return template.Variable(str(self.raw)).resolve(context)
31+
except template.VariableDoesNotExist:
32+
return self.raw
33+
return self.raw
34+
35+
1536
@dataclass
1637
class Param:
1738
name: str
18-
value: str | bool | None
39+
value: Value
1940

20-
def render(self, context: Context) -> str:
21-
if self.value is None or (
22-
isinstance(self.value, str) and self.value == "False"
23-
):
41+
def _resolve_value(self, context: Context) -> str | bool | None:
42+
return self.value.resolve(context)
43+
44+
def render_attr(self, context: Context) -> str:
45+
value = self._resolve_value(context)
46+
if value is None:
2447
return ""
25-
if (isinstance(self.value, bool) and self.value) or (
26-
isinstance(self.value, str) and self.value == "True"
27-
):
48+
if value is True:
2849
return self.name
29-
try:
30-
value = template.Variable(str(self.value)).resolve(context)
31-
except template.VariableDoesNotExist:
32-
value = self.value
3350
return f'{self.name}="{value}"'
3451

52+
def render_prop(self, context: Context) -> str | bool | None:
53+
return self._resolve_value(context)
54+
3555
@classmethod
36-
def from_bit(cls, bit: str):
37-
value: str | bool
56+
def from_bit(cls, bit: str) -> Param:
3857
if "=" in bit:
39-
name, value = bit.split("=", 1)
40-
value = value.strip("'\"")
58+
name, raw_value = bit.split("=", 1)
59+
# Check if the value is quoted
60+
if raw_value.startswith(("'", '"')) and raw_value.endswith(raw_value[0]):
61+
value = Value(raw_value[1:-1], quoted=True)
62+
else:
63+
value = Value(raw_value.strip(), quoted=False)
4164
else:
42-
name, value = bit, True
65+
name, value = bit, Value(True)
4366
return cls(name, value)
4467

4568

@@ -60,23 +83,24 @@ def render_props(self, nodelist: NodeList | None, context: Context):
6083
if not isinstance(node, PropNode):
6184
continue
6285

63-
value: str | bool | None = node.default
86+
# Create a Value instance for the default
87+
value = Value(node.default, quoted=False)
6488

6589
for idx, attr in enumerate(self.attrs):
6690
if node.name == attr.name:
67-
if attr.value:
68-
value = attr.value
91+
if attr.value.raw is not None: # Changed from attr.value
92+
value = attr.value # Now passing the entire Value instance
6993
attrs_to_remove.add(idx)
7094

7195
self.props.append(Param(name=node.name, value=value))
7296

7397
for idx in sorted(attrs_to_remove, reverse=True):
7498
self.attrs.pop(idx)
7599

76-
return {prop.name: prop.value for prop in self.props}
100+
return {prop.name: prop.render_prop(context) for prop in self.props}
77101

78102
def render_attrs(self, context: Context) -> SafeString:
79-
rendered = " ".join(attr.render(context) for attr in self.attrs)
103+
rendered = " ".join(attr.render_attr(context) for attr in self.attrs)
80104
return mark_safe(rendered)
81105

82106
@classmethod

src/django_bird/templatetags/tags/bird.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ def __init__(self, name: str, params: Params, nodelist: NodeList | None) -> None
6060
@override
6161
def render(self, context: Context) -> str:
6262
component_name = self.get_component_name(context)
63+
print(f"self.name: {self.name}")
64+
print(f"component_name: {component_name}")
65+
print(f"component_name type: {type(component_name)}")
6366
component = components.get_component(component_name)
6467
component_context = self.get_component_context_data(component, context)
6568
return component.render(component_context)

tests/templatetags/test_bird.py

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from django_bird.params import Param
1515
from django_bird.params import Params
16+
from django_bird.params import Value
1617
from django_bird.templatetags.tags.bird import END_TAG
1718
from django_bird.templatetags.tags.bird import TAG
1819
from django_bird.templatetags.tags.bird import BirdNode
@@ -48,17 +49,37 @@ def test_missing_argument(self):
4849
@pytest.mark.parametrize(
4950
"params,expected_params",
5051
[
51-
("class='btn'", Params(attrs=[Param(name="class", value="btn")])),
52+
(
53+
"class='btn'",
54+
Params(attrs=[Param(name="class", value=Value("btn", quoted=True))]),
55+
),
5256
(
5357
"class='btn' id='my-btn'",
5458
Params(
5559
attrs=[
56-
Param(name="class", value="btn"),
57-
Param(name="id", value="my-btn"),
60+
Param(name="class", value=Value("btn", quoted=True)),
61+
Param(name="id", value=Value("my-btn", quoted=True)),
62+
]
63+
),
64+
),
65+
("disabled", Params(attrs=[Param(name="disabled", value=Value(True))])),
66+
(
67+
"class=dynamic_class",
68+
Params(
69+
attrs=[
70+
Param(name="class", value=Value("dynamic_class", quoted=False))
71+
]
72+
),
73+
),
74+
(
75+
"class=item.name id=user.id",
76+
Params(
77+
attrs=[
78+
Param(name="class", value=Value("item.name", quoted=False)),
79+
Param(name="id", value=Value("user.id", quoted=False)),
5880
]
5981
),
6082
),
61-
("disabled", Params(attrs=[Param(name="disabled", value=True)])),
6283
],
6384
)
6485
def test_node_params(self, params, expected_params):
@@ -157,6 +178,30 @@ def test_rendered_name(
157178
{"slot": "Click me"},
158179
"<button>Click me</button>",
159180
),
181+
(
182+
"<button {{ attrs }}>Click me</button>",
183+
"{% bird button class=dynamic_class %}Click me{% endbird %}",
184+
{"dynamic_class": "btn-primary"},
185+
'<button class="btn-primary">Click me</button>',
186+
),
187+
(
188+
"<button {{ attrs }}>Click me</button>",
189+
"{% bird button class='dynamic_class' %}Click me{% endbird %}",
190+
{"dynamic_class": "btn-primary"},
191+
'<button class="dynamic_class">Click me</button>',
192+
),
193+
(
194+
"<button {{ attrs }}>Click me</button>",
195+
"{% bird button class=btn.class %}Click me{% endbird %}",
196+
{"btn": {"class": "btn-success"}},
197+
'<button class="btn-success">Click me</button>',
198+
),
199+
(
200+
"<button {{ attrs }}>Click me</button>",
201+
"{% bird button class='btn.class' %}Click me{% endbird %}",
202+
{"btn": {"class": "btn-success"}},
203+
'<button class="btn.class">Click me</button>',
204+
),
160205
],
161206
)
162207
def test_rendered_attrs(
@@ -305,6 +350,36 @@ def get_template_libraries(self, libraries):
305350
{},
306351
'<button class="btn-primary" data-attr="button" disabled>Click me</button>',
307352
),
353+
(
354+
"{% bird:prop class %}<button class='{{ props.class }}'>{{ slot }}</button>",
355+
"{% bird button class=dynamic_class %}Click me{% endbird %}",
356+
{"dynamic_class": "btn-primary"},
357+
"<button class='btn-primary'>Click me</button>",
358+
),
359+
(
360+
"{% bird:prop class %}<button class='{{ props.class }}'>{{ slot }}</button>",
361+
"{% bird button class='dynamic_class' %}Click me{% endbird %}",
362+
{"dynamic_class": "btn-primary"},
363+
"<button class='dynamic_class'>Click me</button>",
364+
),
365+
(
366+
"{% bird:prop class %}<button class='{{ props.class }}'>{{ slot }}</button>",
367+
"{% bird button class=btn.class %}Click me{% endbird %}",
368+
{"btn": {"class": "btn-success"}},
369+
"<button class='btn-success'>Click me</button>",
370+
),
371+
(
372+
"{% bird:prop class %}<button class='{{ props.class }}'>{{ slot }}</button>",
373+
"{% bird button class='btn.class' %}Click me{% endbird %}",
374+
{"btn": {"class": "btn-success"}},
375+
"<button class='btn.class'>Click me</button>",
376+
),
377+
(
378+
"{% bird:prop class='default' %}<button class='{{ props.class }}'>{{ slot }}</button>",
379+
"{% bird button class=active_class %}Click me{% endbird %}",
380+
{"active_class": "active"},
381+
"<button class='active'>Click me</button>",
382+
),
308383
],
309384
)
310385
def test_with_props(

0 commit comments

Comments
 (0)