Skip to content

Commit 9198755

Browse files
Refactor bird templatetag to allow or disallow outside context (#102)
1 parent 4a76afb commit 9198755

File tree

4 files changed

+271
-10
lines changed

4 files changed

+271
-10
lines changed

CHANGELOG.md

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

1919
## [Unreleased]
2020

21+
### Added
22+
23+
- Added `only` keyword to `{% bird %}` tag for isolating component context that, when used, components cannot access their parent template's context, e.g., `{% bird button only %}`.
24+
25+
### Changed
26+
27+
- Changed handling of self-closing indicator (`/`) in `{% bird %}` tag to always treat it as a syntax marker rather adding to the component's template context.
28+
2129
## [0.8.2]
2230

2331
### Fixed

docs/params.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,50 @@ Renders as:
281281
```html
282282
<button class="themed-button" disabled>Click me</button>
283283
```
284+
285+
## Context Isolation
286+
287+
By default, components have access to their parent template's context. This means variables defined in the parent template are available inside the component.
288+
289+
You can use the `only` keyword to prevent a component from accessing its parent context:
290+
291+
```htmldjango
292+
{% bird button only %}
293+
Click me
294+
{% endbird %}
295+
```
296+
297+
When `only` is used:
298+
299+
- The component cannot access variables from the parent context
300+
- Props, slots, and other component-specific context still work normally
301+
- Default values in the component template will be used when parent context variables are not available
302+
303+
### Examples
304+
305+
Without `only`, components can access parent context:
306+
307+
```htmldjango
308+
{# Parent template with user in context #}
309+
{% bird button %}
310+
{{ user.name }} {# Will render "John" #}
311+
{% endbird %}
312+
```
313+
314+
With `only`, parent context is isolated:
315+
316+
```htmldjango
317+
{# Parent template with user in context #}
318+
{% bird button only %}
319+
{{ user.name|default:"Anonymous" }} {# Will render "Anonymous" #}
320+
{% endbird %}
321+
```
322+
323+
Props and slots still work with `only`:
324+
325+
```htmldjango
326+
{% bird button variant="primary" only %}
327+
{% bird:slot prefix %}→{% endbird:slot %}
328+
Submit
329+
{% endbird %}
330+
```

src/django_bird/templatetags/tags/bird.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,20 @@ def do_bird(parser: Parser, token: Token) -> BirdNode:
2626
bits = token.split_contents()
2727
name = parse_bird_name(bits)
2828
attrs = []
29+
only = False
30+
2931
for bit in bits[2:]:
30-
param = Param.from_bit(bit)
31-
attrs.append(param)
32+
match bit:
33+
case "only":
34+
only = True
35+
case "/":
36+
continue
37+
case _:
38+
param = Param.from_bit(bit)
39+
attrs.append(param)
40+
3241
nodelist = parse_nodelist(bits, parser)
33-
return BirdNode(name, attrs, nodelist)
42+
return BirdNode(name, attrs, nodelist, only)
3443

3544

3645
def parse_bird_name(bits: TagBits) -> str:
@@ -57,11 +66,16 @@ def parse_nodelist(bits: TagBits, parser: Parser) -> NodeList | None:
5766

5867
class BirdNode(template.Node):
5968
def __init__(
60-
self, name: str, attrs: list[Param], nodelist: NodeList | None
69+
self,
70+
name: str,
71+
attrs: list[Param],
72+
nodelist: NodeList | None,
73+
only: bool = False,
6174
) -> None:
6275
self.name = name
6376
self.attrs = attrs
6477
self.nodelist = nodelist
78+
self.only = only
6579

6680
@override
6781
def render(self, context: Context) -> str:
@@ -80,14 +94,24 @@ def get_component_name(self, context: Context) -> str:
8094
def get_component_context_data(
8195
self, component: Component, context: Context
8296
) -> dict[str, Any]:
97+
context_data: dict[str, Any] = {}
98+
if not self.only:
99+
flattened = context.flatten()
100+
context_data = {str(k): v for k, v in flattened.items()}
101+
83102
params = Params.with_attrs(self.attrs)
84103
props = params.render_props(component.nodelist, context)
85104
attrs = params.render_attrs(context)
86105
slots = Slots.collect(self.nodelist, context).render()
87106
default_slot = slots.get(DEFAULT_SLOT) or context.get("slot")
88-
return {
89-
"attrs": attrs,
90-
"props": props,
91-
"slot": default_slot,
92-
"slots": slots,
93-
}
107+
108+
context_data.update(
109+
{
110+
"attrs": attrs,
111+
"props": props,
112+
"slot": default_slot,
113+
"slots": slots,
114+
}
115+
)
116+
117+
return context_data

tests/templatetags/test_bird.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,6 +1145,188 @@ def test_nested_components_with_loops(templates_dir, normalize_whitespace):
11451145
""")
11461146

11471147

1148+
@pytest.mark.parametrize(
1149+
"test_case",
1150+
[
1151+
TestComponentCase(
1152+
description="Access parent context variable",
1153+
component=TestComponent(
1154+
name="button",
1155+
content="""
1156+
<button>
1157+
{{ user.name }}
1158+
</button>
1159+
""",
1160+
),
1161+
template_content="""
1162+
{% bird button %}{% endbird %}
1163+
""",
1164+
template_context={"user": {"name": "John"}},
1165+
expected="<button>John</button>",
1166+
),
1167+
TestComponentCase(
1168+
description="Access parent context in slot",
1169+
component=TestComponent(
1170+
name="button",
1171+
content="""
1172+
<button>
1173+
{{ slot }}
1174+
</button>
1175+
""",
1176+
),
1177+
template_content="""
1178+
{% bird button %}
1179+
{{ user.name }}
1180+
{% endbird %}
1181+
""",
1182+
template_context={"user": {"name": "John"}},
1183+
expected="<button>John</button>",
1184+
),
1185+
TestComponentCase(
1186+
description="Access parent context in named slot",
1187+
component=TestComponent(
1188+
name="button",
1189+
content="""
1190+
<button>
1191+
{% bird:slot prefix %}{% endbird:slot %}
1192+
{{ slot }}
1193+
</button>
1194+
""",
1195+
),
1196+
template_content="""
1197+
{% bird button %}
1198+
{% bird:slot prefix %}{{ user.role }}{% endbird:slot %}
1199+
{{ user.name }}
1200+
{% endbird %}
1201+
""",
1202+
template_context={"user": {"name": "John", "role": "Admin"}},
1203+
expected="<button>Admin John</button>",
1204+
),
1205+
TestComponentCase(
1206+
description="Component-specific context overrides parent context values",
1207+
component=TestComponent(
1208+
name="button",
1209+
content="""
1210+
<button {{ attrs }}>
1211+
{{ slot }}
1212+
</button>
1213+
""",
1214+
),
1215+
template_content="""
1216+
{% bird button id="foo" %}
1217+
Component Content
1218+
{% endbird %}
1219+
""",
1220+
template_context={
1221+
"slot": "Parent Content",
1222+
"attrs": 'id="bar"',
1223+
},
1224+
expected='<button id="foo">Component Content</button>',
1225+
),
1226+
],
1227+
ids=lambda x: x.description,
1228+
)
1229+
def test_parent_context_access(test_case, templates_dir, normalize_whitespace):
1230+
test_case.component.create(templates_dir)
1231+
1232+
template = Template(test_case.template_content)
1233+
rendered = template.render(Context(test_case.template_context))
1234+
1235+
assert normalize_whitespace(rendered) == test_case.expected
1236+
1237+
1238+
@pytest.mark.parametrize(
1239+
"test_case",
1240+
[
1241+
TestComponentCase(
1242+
description="Only flag prevents access to parent context",
1243+
component=TestComponent(
1244+
name="button",
1245+
content="""
1246+
<button>
1247+
{{ user.name|default:"Anonymous" }}
1248+
</button>
1249+
""",
1250+
),
1251+
template_content="""
1252+
{% bird button only %}{% endbird %}
1253+
""",
1254+
template_context={"user": {"name": "John"}},
1255+
expected="<button>Anonymous</button>",
1256+
),
1257+
TestComponentCase(
1258+
description="Only flag still allows props and slots",
1259+
component=TestComponent(
1260+
name="button",
1261+
content="""
1262+
{% bird:prop label %}
1263+
<button {{ attrs }}>
1264+
{{ props.label }}
1265+
{{ slot }}
1266+
{{ user.name|default:"Anonymous" }}
1267+
</button>
1268+
""",
1269+
),
1270+
template_content="""
1271+
{% bird button id="foo" label="Click" only %}
1272+
Content
1273+
{% endbird %}
1274+
""",
1275+
template_context={
1276+
"props": {
1277+
"label": "Outside",
1278+
},
1279+
"user": {"name": "John"},
1280+
},
1281+
expected='<button id="foo">Click Content Anonymous</button>',
1282+
),
1283+
TestComponentCase(
1284+
description="Only flag with named slots",
1285+
component=TestComponent(
1286+
name="button",
1287+
content="""
1288+
<button>
1289+
{% bird:slot prefix %}{% endbird:slot %}
1290+
{{ user.name|default:"Anonymous" }}
1291+
</button>
1292+
""",
1293+
),
1294+
template_content="""
1295+
{% bird button only %}
1296+
{% bird:slot prefix %}{{ user.role }}{% endbird:slot %}
1297+
{% endbird %}
1298+
""",
1299+
template_context={"user": {"name": "John", "role": "Admin"}},
1300+
expected="<button>Admin Anonymous</button>",
1301+
),
1302+
TestComponentCase(
1303+
description="Only flag with self-closing tag",
1304+
component=TestComponent(
1305+
name="button",
1306+
content="""
1307+
<button>
1308+
{{ user.name|default:"Anonymous" }}
1309+
</button>
1310+
""",
1311+
),
1312+
template_content="""
1313+
{% bird button only / %}
1314+
""",
1315+
template_context={"user": {"name": "John"}},
1316+
expected="<button>Anonymous</button>",
1317+
),
1318+
],
1319+
ids=lambda x: x.description,
1320+
)
1321+
def test_only_flag(test_case, templates_dir, normalize_whitespace):
1322+
test_case.component.create(templates_dir)
1323+
1324+
template = Template(test_case.template_content)
1325+
rendered = template.render(Context(test_case.template_context))
1326+
1327+
assert normalize_whitespace(rendered) == test_case.expected
1328+
1329+
11481330
class TestBirdNode:
11491331
@pytest.mark.parametrize(
11501332
"test_case",

0 commit comments

Comments
 (0)