Skip to content

Commit c0bd2f5

Browse files
Add sequence to data-bird-id attr and add component name data attr (#135)
1 parent 3f3fddb commit c0bd2f5

File tree

9 files changed

+243
-29
lines changed

9 files changed

+243
-29
lines changed

CHANGELOG.md

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

1919
## [Unreleased]
2020

21+
### Added
22+
23+
- Added `data-bird-<component_name>` data attribute to the `attrs` template context variable for components when `ENABLE_BIRD_ATTRS` is enabled.
24+
2125
### Changed
2226

2327
- **Internal**: Refactored component rendering by introducing a new `BoundComponent` class and moving some of the rendering logic from `Component` and `BirdNode` to this new class.
28+
- Renamed `ENABLE_BIRD_ID_ATTR` setting to `ENABLE_BIRD_ATTRS` to reflect its expanded functionality.
29+
- Moved setting the `data-bird-id` data attribute in the `attrs` template context variable to `BoundComponent` and added a sequence number to better uniquely identify multiple instances of the same component.
30+
31+
### Deprecated
32+
33+
- The `ENABLE_BIRD_ID_ATTR` setting is deprecated and will be removed in the next minor version (v0.13.0). Use `ENABLE_BIRD_ATTRS` instead.
2434

2535
### Removed
2636

docs/configuration.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ from pathlib import Path
1212
DJANGO_BIRD = {
1313
"COMPONENT_DIRS": list[Path | str] = [],
1414
"ENABLE_AUTO_CONFIG": bool = True,
15-
"ENABLE_BIRD_ID_ATTR": bool = True,
15+
"ENABLE_BIRD_ATTRS": bool = True,
1616
}
1717
```
1818

@@ -112,8 +112,12 @@ TEMPLATES = [
112112

113113
This configuration ensures that django-bird's templatetags are available globally and that its loader is used to compile bird component templates before the standard Django loaders.
114114

115-
## `ENABLE_BIRD_ID_ATTR`
115+
## `ENABLE_BIRD_ATTRS`
116116

117-
Controls whether components automatically receive a `data-bird-id` attribute containing a unique identifier. Defaults to `True`.
117+
Controls whether components automatically receive data attributes related to django-bird in its `attrs` template context variable. Defaults to `True`.
118118

119119
See [Component ID Attribute](params.md#component-id-attribute) for more details on how this works.
120+
121+
```{important}
122+
This setting was previously named `ENABLE_BIRD_ID_ATTRS`. It was renamed to reflect the expansion of the data attributes for a component, from just `data-bird-id` to include `data-bird-<component_name>`, as well as to allow for future expansion. As of v0.12.0, the `ENABLE_BIRD_ID_ATTRS` setting is deprecated and will be removed in v0.13.0.
123+
```

docs/params.md

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,12 @@ This will render as:
8787

8888
### Component ID Attribute
8989

90-
When the [`ENABLE_BIRD_ID_ATTR` setting](configuration.md#enable_bird_id_attr) is enabled (the default), django-bird automatically adds a `data-bird-id` attribute to your components, available via the `{{ attrs }}` context variable. This attribute contains a unique identifier that helps identify specific component instances in the DOM.
90+
When the [`ENABLE_BIRD_ATTRS` setting](configuration.md#enable_bird_attrs) is enabled (the default), django-bird automatically adds data attributes to your components, available via the `{{ attrs }}` context variable.
91+
92+
The following attributes are included:
93+
94+
- `data-bird-<component_name>`: This attribute will contain the name of the component. This is not unique across component instances in the DOM.
95+
- `data-bird-id`: This attribute contains a unique identifier that helps identify specific component instances in the DOM.
9196

9297
For example, for a component template like this:
9398

@@ -108,22 +113,50 @@ And used like this:
108113
It will be rendered as:
109114

110115
```html
111-
<button data-bird-id="abc1234" class="btn">
116+
<button class="btn" data-bird-button data-bird-id="abc1234-1">
112117
Click me
113118
</button>
114119
```
115120

116-
The ID is automatically generated from a hash of the component's name and template content.
121+
The ID is automatically generated from a hash of the component's name and template content. It also contains a sequence counter that will increment for any uses of a component across a single template.
122+
123+
The above example button component template, used like this:
124+
125+
```htmldjango
126+
{% bird button class="btn" %}
127+
Click me once
128+
{% endbird %}
129+
{% bird button class="btn" %}
130+
Click me twice
131+
{% endbird %}
132+
{% bird button class="btn" %}
133+
Click me three times a lady
134+
{% endbird %}
135+
```
136+
137+
Will be rendered like this, with the unique sequence numbers added to the component's hashed ID:
138+
139+
```html
140+
<button class="btn" data-bird-button data-bird-id="abc1234-1">
141+
Click me once
142+
</button>
143+
<button class="btn" data-bird-button data-bird-id="abc1234-2">
144+
Click me twice
145+
</button>
146+
<button class="btn" data-bird-button data-bird-id="abc1234-3">
147+
Click me three times a lady
148+
</button>
149+
```
117150

118-
You can disable this feature globally by setting `ENABLE_BIRD_ID_ATTR = False` in your Django settings:
151+
You can disable this feature globally by setting `ENABLE_BIRD_ATTRS = False` in your Django settings:
119152

120153
```python
121154
DJANGO_BIRD = {
122-
"ENABLE_BIRD_ID_ATTR": False,
155+
"ENABLE_BIRD_ATTRS": False,
123156
}
124157
```
125158

126-
When disabled, no `data-bird-id` attribute will be added to your components.
159+
When disabled, no data attributes will be added to your components' `attrs` template context variable.
127160

128161
## Properties
129162

src/django_bird/components.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from dataclasses import field
55
from hashlib import md5
66
from pathlib import Path
7+
from threading import Lock
78

89
from cachetools import LRUCache
910
from django.apps import apps
@@ -40,6 +41,10 @@ def get_bound_component(self, attrs: list[Param]):
4041
params = Params.with_attrs(attrs)
4142
return BoundComponent(component=self, params=params)
4243

44+
@property
45+
def data_attribute_name(self):
46+
return self.name.replace(".", "-")
47+
4348
@property
4449
def id(self):
4550
normalized_source = "".join(self.source.split())
@@ -76,16 +81,45 @@ def from_name(cls, name: str):
7681
return cls(name=name, template=template, assets=frozenset(assets))
7782

7883

84+
class SequenceGenerator:
85+
_instance: SequenceGenerator | None = None
86+
_lock: Lock = Lock()
87+
_counters: dict[str, int]
88+
89+
def __init__(self) -> None:
90+
if not hasattr(self, "_counters"):
91+
self._counters = {}
92+
93+
def __new__(cls) -> SequenceGenerator:
94+
if cls._instance is None:
95+
with cls._lock:
96+
if cls._instance is None:
97+
cls._instance = super().__new__(cls)
98+
return cls._instance
99+
100+
def next(self, component: Component) -> int:
101+
with self._lock:
102+
current = self._counters.get(component.id, 0) + 1
103+
self._counters[component.id] = current
104+
return current
105+
106+
79107
@dataclass
80108
class BoundComponent:
81109
component: Component
82110
params: Params
111+
_sequence: SequenceGenerator = field(default_factory=SequenceGenerator)
83112

84113
def render(self, context: Context):
85-
if app_settings.ENABLE_BIRD_ID_ATTR:
86-
self.params.attrs.append(
87-
Param("data_bird_id", Value(self.component.id, True))
88-
)
114+
if app_settings.ENABLE_BIRD_ATTRS:
115+
data_attrs = [
116+
Param(
117+
f"data-bird-{self.component.data_attribute_name}",
118+
Value(True, False),
119+
),
120+
Param("data-bird-id", Value(f"{self.component.id}-{self.id}", True)),
121+
]
122+
self.params.attrs.extend(data_attrs)
89123

90124
props = self.params.render_props(self.component.nodelist, context)
91125
attrs = self.params.render_attrs(context)
@@ -98,6 +132,10 @@ def render(self, context: Context):
98132
):
99133
return self.component.template.template.render(context)
100134

135+
@property
136+
def id(self):
137+
return str(self._sequence.next(self.component))
138+
101139

102140
class ComponentRegistry:
103141
def __init__(self, maxsize: int = 100):

src/django_bird/conf.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import warnings
34
from contextlib import suppress
45
from dataclasses import dataclass
56
from dataclasses import field
@@ -18,10 +19,17 @@
1819
class AppSettings:
1920
COMPONENT_DIRS: list[Path | str] = field(default_factory=list)
2021
ENABLE_AUTO_CONFIG: bool = True
21-
ENABLE_BIRD_ID_ATTR: bool = True
22+
ENABLE_BIRD_ATTRS: bool = True
23+
ENABLE_BIRD_ID_ATTR: bool | None = None
2224
_template_configurator: TemplateConfigurator = field(init=False)
2325

2426
def __post_init__(self):
27+
if self.ENABLE_BIRD_ID_ATTR is not None:
28+
warnings.warn(
29+
"ENABLE_BIRD_ID_ATTR is deprecated and will be removed in v0.13.0. Use ENABLE_BIRD_ATTRS instead.",
30+
DeprecationWarning,
31+
stacklevel=2,
32+
)
2533
self._template_configurator = TemplateConfigurator(self)
2634

2735
@override

tests/conftest.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,9 @@ def _override_app_settings(**kwargs):
109109

110110
@pytest.fixture(autouse=True)
111111
def data_bird_attr_app_setting(override_app_settings, request):
112-
from django_bird.conf import app_settings
112+
enable = "default_app_settings" in request.keywords
113113

114-
enable = (
115-
app_settings.ENABLE_BIRD_ID_ATTR
116-
if "default_app_settings" in request.keywords
117-
else False
118-
)
119-
120-
with override_app_settings(ENABLE_BIRD_ID_ATTR=enable):
114+
with override_app_settings(ENABLE_BIRD_ATTRS=enable, ENABLE_BIRD_ID_ATTR=enable):
121115
yield
122116

123117

tests/templatetags/test_bird.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -634,8 +634,22 @@ def test_error_handling(self, test_case, templates_dir, normalize_whitespace):
634634

635635
assert normalize_whitespace(rendered) == test_case.expected
636636

637-
def test_data_bird_id(
638-
self, override_app_settings, templates_dir, normalize_whitespace
637+
@pytest.mark.parametrize(
638+
"attr_app_setting,expected",
639+
[
640+
({"ENABLE_BIRD_ATTRS": True, "ENABLE_BIRD_ID_ATTR": True}, True),
641+
({"ENABLE_BIRD_ATTRS": True, "ENABLE_BIRD_ID_ATTR": False}, True),
642+
({"ENABLE_BIRD_ATTRS": False, "ENABLE_BIRD_ID_ATTR": True}, False),
643+
({"ENABLE_BIRD_ATTRS": False, "ENABLE_BIRD_ID_ATTR": False}, False),
644+
],
645+
)
646+
def test_data_bird_attributes(
647+
self,
648+
attr_app_setting,
649+
expected,
650+
override_app_settings,
651+
templates_dir,
652+
normalize_whitespace,
639653
):
640654
button = TestComponent(
641655
name="button",
@@ -648,12 +662,13 @@ def test_data_bird_id(
648662

649663
template = Template("{% bird 'button' %}Click me{% endbird %}")
650664

651-
with override_app_settings(ENABLE_BIRD_ID_ATTR=True):
665+
with override_app_settings(**attr_app_setting):
652666
rendered = template.render(Context({}))
653667

654668
comp = Component.from_name(button.name)
655669

656-
assert comp.id in rendered
670+
assert (f'data-bird-id="{comp.id}' in rendered) is expected
671+
assert (f"data-bird-{comp.data_attribute_name}" in rendered) is expected
657672

658673

659674
class TestProperties:

tests/test_components.py

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from pathlib import Path
1010

1111
import pytest
12-
from django.template.backends.django import Template
12+
from django.template import Template
13+
from django.template.backends.django import Template as DjangoTemplate
1314
from django.template.context import Context
1415
from django.template.exceptions import TemplateDoesNotExist
1516
from django.test import override_settings
@@ -33,7 +34,7 @@ def test_from_name_basic(self, templates_dir):
3334
comp = Component.from_name("button")
3435

3536
assert comp.name == "button"
36-
assert isinstance(comp.template, Template)
37+
assert isinstance(comp.template, DjangoTemplate)
3738

3839
bound = comp.get_bound_component([])
3940

@@ -106,7 +107,7 @@ def test_from_name_custom_component_dir(self, templates_dir, override_app_settin
106107
comp = Component.from_name("button")
107108

108109
assert comp.name == "button"
109-
assert isinstance(comp.template, Template)
110+
assert isinstance(comp.template, DjangoTemplate)
110111

111112
bound = comp.get_bound_component([])
112113

@@ -169,6 +170,80 @@ def test_id_formatting(self, templates_dir):
169170
assert len(comp.id) == 7
170171
assert all(c in "0123456789abcdef" for c in comp.id)
171172

173+
def test_data_attribute_name_basic(self, templates_dir):
174+
button = TestComponent(
175+
name="button", content="<button>Click me</button>"
176+
).create(templates_dir)
177+
178+
comp = Component.from_name(button.name)
179+
180+
assert comp.data_attribute_name == "button"
181+
182+
def test_data_attribute_name_nested(self, templates_dir):
183+
button = TestComponent(
184+
name="button",
185+
content="<button>Nested</button>",
186+
sub_dir="nested",
187+
).create(templates_dir)
188+
189+
comp = Component.from_name(f"{button.sub_dir}.{button.name}")
190+
191+
assert comp.data_attribute_name == "nested-button"
192+
193+
194+
class TestBoundComponent:
195+
@pytest.mark.parametrize(
196+
"attr_app_setting,expected",
197+
[
198+
({"ENABLE_BIRD_ATTRS": True, "ENABLE_BIRD_ID_ATTR": True}, True),
199+
({"ENABLE_BIRD_ATTRS": True, "ENABLE_BIRD_ID_ATTR": False}, True),
200+
({"ENABLE_BIRD_ATTRS": False, "ENABLE_BIRD_ID_ATTR": True}, False),
201+
({"ENABLE_BIRD_ATTRS": False, "ENABLE_BIRD_ID_ATTR": False}, False),
202+
],
203+
)
204+
def test_id_sequence(
205+
self,
206+
attr_app_setting,
207+
expected,
208+
override_app_settings,
209+
templates_dir,
210+
normalize_whitespace,
211+
):
212+
button = TestComponent(
213+
name="button", content="<button {{ attrs }}>{{ slot }}</button>"
214+
).create(templates_dir)
215+
comp = Component.from_name(button.name)
216+
217+
template = Template("""
218+
{% bird button class="btn" %}
219+
Click me once
220+
{% endbird %}
221+
{% bird button class="btn" %}
222+
Click me twice
223+
{% endbird %}
224+
{% bird button class="btn" %}
225+
Click me three times a lady
226+
{% endbird %}
227+
""")
228+
229+
with override_app_settings(**attr_app_setting):
230+
rendered = template.render(Context({}))
231+
232+
assert (
233+
normalize_whitespace(rendered)
234+
== normalize_whitespace(f"""
235+
<button class="btn" data-bird-button data-bird-id="{comp.id}-1">
236+
Click me once
237+
</button>
238+
<button class="btn" data-bird-button data-bird-id="{comp.id}-2">
239+
Click me twice
240+
</button>
241+
<button class="btn" data-bird-button data-bird-id="{comp.id}-3">
242+
Click me three times a lady
243+
</button>
244+
""")
245+
) is expected
246+
172247

173248
class TestComponentRegistryProject:
174249
def test_discover_components(self, templates_dir):

0 commit comments

Comments
 (0)