From 93b8a7404a1003525cbdd37b36cd3211204ef78a Mon Sep 17 00:00:00 2001 From: rbeard0330 Date: Mon, 28 Dec 2020 04:40:35 -0500 Subject: [PATCH] Rework of context handling (#18) Co-authored-by: rbeard0330 <@dul2k3BKW6m> --- django_components/component.py | 63 ++--- .../templatetags/component_tags.py | 36 +-- tests/templates/child_template.html | 0 tests/templates/incrementer.html | 2 + tests/templates/parent_template.html | 12 + .../templates/parent_with_args_template.html | 12 + tests/templates/variable_display.html | 4 + tests/test_component.py | 8 +- tests/test_context.py | 227 ++++++++++++++++++ 9 files changed, 305 insertions(+), 59 deletions(-) create mode 100644 tests/templates/child_template.html create mode 100644 tests/templates/incrementer.html create mode 100644 tests/templates/parent_template.html create mode 100644 tests/templates/parent_with_args_template.html create mode 100644 tests/templates/variable_display.html create mode 100644 tests/test_context.py diff --git a/django_components/component.py b/django_components/component.py index 7ae000bd..cc7cd8c0 100644 --- a/django_components/component.py +++ b/django_components/component.py @@ -1,5 +1,4 @@ from django.forms.widgets import MediaDefiningClass -from django.template import Context from django.template.base import NodeList, TextNode from django.template.loader import get_template from django.utils.safestring import mark_safe @@ -8,12 +7,6 @@ from six import with_metaclass # Allow "component.AlreadyRegistered" instead of having to import these everywhere from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered # noqa -# Python 2 compatibility -try: - from inspect import getfullargspec -except ImportError: - from inspect import getargspec as getfullargspec - # Django < 2.1 compatibility try: from django.template.base import TokenType @@ -49,52 +42,40 @@ class Component(with_metaclass(MediaDefiningClass)): return mark_safe("\n".join(self.media.render_js())) def slots_in_template(self, template): - nodelist = NodeList() - for node in template.template.nodelist: - if ( - node.token.token_type == TokenType.BLOCK - and node.token.split_contents()[0] == "slot" - ): - nodelist.append(node) + return NodeList(node for node in template.template.nodelist if is_slot_node(node)) - return nodelist - - def render(self, slots_filled=None, *args, **kwargs): + def render(self, context, slots_filled=None): slots_filled = slots_filled or [] - context_args_variables = getfullargspec(self.context).args[1:] - context_args = { - key: kwargs[key] for key in context_args_variables if key in kwargs - } - context = self.context(**context_args) template = get_template(self.template(context)) slots_in_template = self.slots_in_template(template) - if slots_in_template: - valid_slot_names = set([slot.name for slot in slots_in_template]) - nodelist = NodeList() - for node in template.template.nodelist: - if ( - node.token.token_type == TokenType.BLOCK - and node.token.split_contents()[0] == "slot" - ): - if node.name in valid_slot_names and node.name in slots_filled: - nodelist.append(TextNode(slots_filled[node.name])) - else: - for node in node.nodelist: - nodelist.append(node) + # If there are no slots, then we can simply render the template + if not slots_in_template: + return template.template.render(context) + + # Otherwise, we need to assemble and render a nodelist containing the nodes from the template, slots that were + # provided when the component was called (previously rendered by the component's render method) and the + # unrendered default slots + nodelist = NodeList() + for node in template.template.nodelist: + if is_slot_node(node): + if node.name in slots_filled: + nodelist.append(TextNode(slots_filled[node.name])) else: - nodelist.append(node) + nodelist.extend(node.nodelist) + else: + nodelist.append(node) - render_context = Context(context) - with render_context.bind_template(template.template): - return nodelist.render(render_context) - - return template.render(context) + return nodelist.render(context) class Media: css = {} js = [] +def is_slot_node(node): + return node.token.token_type == TokenType.BLOCK and node.token.split_contents()[0] == "slot" + + # This variable represents the global component registry registry = ComponentRegistry() diff --git a/django_components/templatetags/component_tags.py b/django_components/templatetags/component_tags.py index e51301c1..c07443e8 100644 --- a/django_components/templatetags/component_tags.py +++ b/django_components/templatetags/component_tags.py @@ -88,7 +88,8 @@ def component_js_dependencies_tag(): def component_tag(name, *args, **kwargs): component_class = registry.get(name) component = component_class() - return component.render(*args, **kwargs) + context = template.Context(component.context(*args, **kwargs)) + return component.render(context) class SlotNode(Node): @@ -129,27 +130,30 @@ def do_slot(parser, token, component=None): class ComponentNode(Node): - def __init__(self, component, extra_context, slots): - extra_context = extra_context or {} - self.component, self.extra_context, self.slots = component, extra_context, slots + def __init__(self, component, context_kwargs, slots): + self.context_kwargs = context_kwargs or {} + self.component, self.slots = component, slots def __repr__(self): return "" % (self.component, self.slots) def render(self, context): - extra_context = { + # Resolve FilterExpressions and Variables that were passed as args to the component, then call component's + # context method to get values to insert into the context + resolved_context_kwargs = { key: context_item.resolve(context) if hasattr(context_item, 'resolve') else context_item - for key, context_item in self.extra_context.items() + for key, context_item in self.context_kwargs.items() } - context.update(extra_context) + extra_context = self.component.context(**resolved_context_kwargs) - self.slots.render(context) + with context.update(extra_context): + self.slots.render(context) + if COMPONENT_CONTEXT_KEY in context.render_context: + slots_filled = context.render_context[COMPONENT_CONTEXT_KEY][self.component] + else: + slots_filled = [] - if COMPONENT_CONTEXT_KEY in context.render_context: - slots_filled = context.render_context[COMPONENT_CONTEXT_KEY][self.component] - return self.component.render(slots_filled=slots_filled, **context.flatten()) - - return self.component.render(**extra_context) + return self.component.render(context, slots_filled=slots_filled) @register.tag("component_block") @@ -187,9 +191,9 @@ def do_component(parser, token): component_class = registry.get(component_name) component = component_class() - extra_context = {} + context_kwargs = {} if len(bits) > 2: - extra_context = component.context(**token_kwargs(bits[2:], parser)) + context_kwargs = token_kwargs(bits[2:], parser) slots_filled = NodeList() tag_name = bits[0] @@ -205,4 +209,4 @@ def do_component(parser, token): elif tag_name == "endcomponent_block": break - return ComponentNode(component, extra_context, slots_filled) + return ComponentNode(component, context_kwargs, slots_filled) diff --git a/tests/templates/child_template.html b/tests/templates/child_template.html new file mode 100644 index 00000000..e69de29b diff --git a/tests/templates/incrementer.html b/tests/templates/incrementer.html new file mode 100644 index 00000000..db172128 --- /dev/null +++ b/tests/templates/incrementer.html @@ -0,0 +1,2 @@ +{% load component_tags %}

value={{ value }};calls={{ calls }}

+{% slot 'content' %}{% endslot %} \ No newline at end of file diff --git a/tests/templates/parent_template.html b/tests/templates/parent_template.html new file mode 100644 index 00000000..47f2098d --- /dev/null +++ b/tests/templates/parent_template.html @@ -0,0 +1,12 @@ +{% load component_tags %} + +
+

Parent content

+ {% component name="variable_display" shadowing_variable='override' new_variable='unique_val' %} +
+
+ {% slot 'content' %} +

Slot content

+ {% component name="variable_display" shadowing_variable='slot_default_override' new_variable='slot_default_unique' %} + {% endslot %} +
\ No newline at end of file diff --git a/tests/templates/parent_with_args_template.html b/tests/templates/parent_with_args_template.html new file mode 100644 index 00000000..b5c25555 --- /dev/null +++ b/tests/templates/parent_with_args_template.html @@ -0,0 +1,12 @@ +{% load component_tags %} + +
+

Parent content

+ {% component name="variable_display" shadowing_variable=inner_parent_value new_variable='unique_val' %} +
+
+ {% slot 'content' %} +

Slot content

+ {% component name="variable_display" shadowing_variable='slot_default_override' new_variable=inner_parent_value %} + {% endslot %} +
\ No newline at end of file diff --git a/tests/templates/variable_display.html b/tests/templates/variable_display.html new file mode 100644 index 00000000..7fe133c6 --- /dev/null +++ b/tests/templates/variable_display.html @@ -0,0 +1,4 @@ +{% load component_tags %} + +

Shadowing variable = {{ shadowing_variable }}

+

Uniquely named variable = {{ unique_variable }}

\ No newline at end of file diff --git a/tests/test_component.py b/tests/test_component.py index cc7f6fe1..236aab71 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -1,5 +1,7 @@ from textwrap import dedent +from django.template import Context + from django_components import component from .django_test_setup import * # NOQA @@ -29,13 +31,14 @@ class ComponentRegistryTest(SimpleTestCase): js = ["script.js"] comp = SimpleComponent() + context = Context(comp.context(variable="test")) self.assertHTMLEqual(comp.render_dependencies(), dedent(""" """).strip()) - self.assertHTMLEqual(comp.render(variable="test"), dedent(""" + self.assertHTMLEqual(comp.render(context), dedent(""" Variable: test """).lstrip()) @@ -66,8 +69,9 @@ class ComponentRegistryTest(SimpleTestCase): return "filtered_template.html" comp = FilteredComponent() + context = Context(comp.context(var1="test1", var2="test2")) - self.assertHTMLEqual(comp.render(var1="test1", var2="test2"), dedent(""" + self.assertHTMLEqual(comp.render(context), dedent(""" Var1: test1 Var2 (uppercased): TEST2 """).lstrip()) diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 00000000..5e17ba2d --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,227 @@ +from django.template import Context, Template + +from django_components import component + +from .django_test_setup import * # NOQA +from .testutils import Django111CompatibleSimpleTestCase as SimpleTestCase + + +class ParentComponent(component.Component): + def context(self): + return { + "shadowing_variable": 'NOT SHADOWED' + } + + def template(self, context): + return "parent_template.html" + + +class ParentComponentWithArgs(component.Component): + def context(self, parent_value): + return { + "inner_parent_value": parent_value + } + + def template(self, context): + return "parent_with_args_template.html" + + +class VariableDisplay(component.Component): + def context(self, shadowing_variable, new_variable): + return { + "shadowing_variable": shadowing_variable, + "unique_variable": new_variable + } + + def template(self, context): + return "variable_display.html" + + +class IncrementerComponent(component.Component): + def context(self, value=0): + value = int(value) + if hasattr(self, 'call_count'): + self.call_count += 1 + else: + self.call_count = 1 + return { + "value": value + 1, + "calls": self.call_count + } + + def template(self, context): + return "incrementer.html" + + +component.registry.register(name='parent_component', component=ParentComponent) +component.registry.register(name='parent_with_args', component=ParentComponentWithArgs) +component.registry.register(name='variable_display', component=VariableDisplay) +component.registry.register(name='incrementer', component=IncrementerComponent) + + +class ContextTests(SimpleTestCase): + def test_nested_component_context_shadows_parent_with_unfilled_slots_and_component_tag(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component 'parent_component' %}") + rendered = template.render(Context()) + + self.assertIn('

Shadowing variable = override

', rendered, rendered) + self.assertIn('

Shadowing variable = slot_default_override

', rendered, rendered) + self.assertNotIn('

Shadowing variable = NOT SHADOWED

', rendered, rendered) + + def test_nested_component_instances_have_unique_context_with_unfilled_slots_and_component_tag(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component name='parent_component' %}") + rendered = template.render(Context()) + + self.assertIn('

Uniquely named variable = unique_val

', rendered, rendered) + self.assertIn('

Uniquely named variable = slot_default_unique

', rendered, rendered) + + def test_nested_component_context_shadows_parent_with_unfilled_slots_and_component_block_tag(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component_block 'parent_component' %}{% endcomponent_block %}") + rendered = template.render(Context()) + + self.assertIn('

Shadowing variable = override

', rendered, rendered) + self.assertIn('

Shadowing variable = slot_default_override

', rendered, rendered) + self.assertNotIn('

Shadowing variable = NOT SHADOWED

', rendered, rendered) + + def test_nested_component_instances_have_unique_context_with_unfilled_slots_and_component_block_tag(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component_block 'parent_component' %}{% endcomponent_block %}") + rendered = template.render(Context()) + + self.assertIn('

Uniquely named variable = unique_val

', rendered, rendered) + self.assertIn('

Uniquely named variable = slot_default_unique

', rendered, rendered) + + def test_nested_component_context_shadows_parent_with_filled_slots(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component_block 'parent_component' %}" + "{% slot 'content' %}{% component name='variable_display' " + "shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endslot %}" + "{% endcomponent_block %}") + rendered = template.render(Context()) + + self.assertIn('

Shadowing variable = override

', rendered, rendered) + self.assertIn('

Shadowing variable = shadow_from_slot

', rendered, rendered) + self.assertNotIn('

Shadowing variable = NOT SHADOWED

', rendered, rendered) + + def test_nested_component_instances_have_unique_context_with_filled_slots(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component_block 'parent_component' %}" + "{% slot 'content' %}{% component name='variable_display' " + "shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endslot %}" + "{% endcomponent_block %}") + rendered = template.render(Context()) + + self.assertIn('

Uniquely named variable = unique_val

', rendered, rendered) + self.assertIn('

Uniquely named variable = unique_from_slot

', rendered, rendered) + + def test_nested_component_context_shadows_outer_context_with_unfilled_slots_and_component_tag(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component name='parent_component' %}") + rendered = template.render(Context({'shadowing_variable': 'NOT SHADOWED'})) + + self.assertIn('

Shadowing variable = override

', rendered, rendered) + self.assertIn('

Shadowing variable = slot_default_override

', rendered, rendered) + self.assertNotIn('

Shadowing variable = NOT SHADOWED

', rendered, rendered) + + def test_nested_component_context_shadows_outer_context_with_unfilled_slots_and_component_block_tag(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component_block 'parent_component' %}{% endcomponent_block %}") + rendered = template.render(Context({'shadowing_variable': 'NOT SHADOWED'})) + + self.assertIn('

Shadowing variable = override

', rendered, rendered) + self.assertIn('

Shadowing variable = slot_default_override

', rendered, rendered) + self.assertNotIn('

Shadowing variable = NOT SHADOWED

', rendered, rendered) + + def test_nested_component_context_shadows_outer_context_with_filled_slots(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component_block 'parent_component' %}" + "{% slot 'content' %}{% component name='variable_display' " + "shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}{% endslot %}" + "{% endcomponent_block %}") + rendered = template.render(Context({'shadowing_variable': 'NOT SHADOWED'})) + + self.assertIn('

Shadowing variable = override

', rendered, rendered) + self.assertIn('

Shadowing variable = shadow_from_slot

', rendered, rendered) + self.assertNotIn('

Shadowing variable = NOT SHADOWED

', rendered, rendered) + + +class ParentArgsTests(SimpleTestCase): + def test_parent_args_can_be_drawn_from_context(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component_block 'parent_with_args' parent_value=parent_value %}" + "{% endcomponent_block %}") + rendered = template.render(Context({'parent_value': 'passed_in'})) + + self.assertIn('

Shadowing variable = passed_in

', rendered, rendered) + self.assertIn('

Uniquely named variable = passed_in

', rendered, rendered) + self.assertNotIn('

Shadowing variable = NOT SHADOWED

', rendered, rendered) + + def test_parent_args_available_outside_slots(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component_block 'parent_with_args' parent_value='passed_in' %}{%endcomponent_block %}") + rendered = template.render(Context()) + + self.assertIn('

Shadowing variable = passed_in

', rendered, rendered) + self.assertIn('

Uniquely named variable = passed_in

', rendered, rendered) + self.assertNotIn('

Shadowing variable = NOT SHADOWED

', rendered, rendered) + + def test_parent_args_available_in_slots(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component_block 'parent_with_args' parent_value='passed_in' %}" + "{% slot 'content' %}{% component name='variable_display' " + "shadowing_variable='value_from_slot' new_variable=inner_parent_value %}{% endslot %}" + "{%endcomponent_block %}") + rendered = template.render(Context()) + + self.assertIn('

Shadowing variable = value_from_slot

', rendered, rendered) + self.assertIn('

Uniquely named variable = passed_in

', rendered, rendered) + self.assertNotIn('

Shadowing variable = NOT SHADOWED

', rendered, rendered) + + +class ContextCalledOnceTests(SimpleTestCase): + def test_one_context_call_with_simple_component(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component name='incrementer' %}") + rendered = template.render(Context()).strip() + + self.assertEqual(rendered, '

value=1;calls=1

', rendered) + + def test_one_context_call_with_simple_component_and_arg(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component name='incrementer' value='2' %}") + rendered = template.render(Context()).strip() + + self.assertEqual(rendered, '

value=3;calls=1

', rendered) + + def test_one_context_call_with_component_block(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component_block 'incrementer' %}{% endcomponent_block %}") + rendered = template.render(Context()).strip() + + self.assertEqual(rendered, '

value=1;calls=1

', rendered) + + def test_one_context_call_with_component_block_and_arg(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component_block 'incrementer' value='3' %}{% endcomponent_block %}") + rendered = template.render(Context()).strip() + + self.assertEqual(rendered, '

value=4;calls=1

', rendered) + + def test_one_context_call_with_slot(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component_block 'incrementer' %}{% slot 'content' %}" + "

slot

{% endslot %}{% endcomponent_block %}") + rendered = template.render(Context()).strip() + + self.assertEqual(rendered, '

value=1;calls=1

\n

slot

', rendered) + + def test_one_context_call_with_slot_and_arg(self): + template = Template("{% load component_tags %}{% component_dependencies %}" + "{% component_block 'incrementer' value='3' %}{% slot 'content' %}" + "

slot

{% endslot %}{% endcomponent_block %}") + rendered = template.render(Context()).strip() + + self.assertEqual(rendered, '

value=4;calls=1

\n

slot

', rendered)