Improve slot handling to allow nested components, conditional slots, and slot.super (Fixes #33, #34, #37)

Co-authored-by: rbeard0330 <@dul2k3BKW6m>
This commit is contained in:
rbeard0330 2021-05-25 18:55:40 -04:00 committed by Emil Stenström
parent c4db1646db
commit 070b754d24
7 changed files with 459 additions and 69 deletions

View file

@ -91,8 +91,7 @@ class RenderBenchmarks(SimpleTestCase):
"{% component 'inner_component' variable='foo' %}{% endslot %}{% endcomponent_block %}", "{% component 'inner_component' variable='foo' %}{% endslot %}{% endcomponent_block %}",
name='root') name='root')
# Sanity tests # Sanity tests
response = create_and_process_template_response(template) response_content = create_and_process_template_response(template)
response_content = response.content.decode('utf-8')
self.assertNotIn(CSS_DEPENDENCY_PLACEHOLDER, response_content) self.assertNotIn(CSS_DEPENDENCY_PLACEHOLDER, response_content)
self.assertNotIn(JS_DEPENDENCY_PLACEHOLDER, response_content) self.assertNotIn(JS_DEPENDENCY_PLACEHOLDER, response_content)
self.assertIn('style.css', response_content) self.assertIn('style.css', response_content)
@ -109,8 +108,7 @@ class RenderBenchmarks(SimpleTestCase):
from django.template.loader import get_template from django.template.loader import get_template
template = get_template('mdn_complete_page.html') template = get_template('mdn_complete_page.html')
response = create_and_process_template_response(template, {}) response_content = create_and_process_template_response(template, {})
response_content = response.content.decode('utf-8')
self.assertNotIn(CSS_DEPENDENCY_PLACEHOLDER, response_content) self.assertNotIn(CSS_DEPENDENCY_PLACEHOLDER, response_content)
self.assertNotIn(JS_DEPENDENCY_PLACEHOLDER, response_content) self.assertNotIn(JS_DEPENDENCY_PLACEHOLDER, response_content)
self.assertIn('test.css', response_content) self.assertIn('test.css', response_content)

View file

@ -1,11 +1,10 @@
import warnings import warnings
from copy import copy from copy import copy, deepcopy
from functools import lru_cache from functools import lru_cache
from itertools import chain
from django.conf import settings from django.conf import settings
from django.forms.widgets import MediaDefiningClass from django.forms.widgets import MediaDefiningClass
from django.template.base import NodeList, TokenType from django.template.base import Node, NodeList, TokenType
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -14,6 +13,7 @@ from django_components.component_registry import AlreadyRegistered, ComponentReg
TEMPLATE_CACHE_SIZE = getattr(settings, "COMPONENTS", {}).get('TEMPLATE_CACHE_SIZE', 128) TEMPLATE_CACHE_SIZE = getattr(settings, "COMPONENTS", {}).get('TEMPLATE_CACHE_SIZE', 128)
class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass): class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
def __new__(mcs, name, bases, attrs): def __new__(mcs, name, bases, attrs):
if "Media" in attrs: if "Media" in attrs:
@ -39,6 +39,7 @@ class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
return super().__new__(mcs, name, bases, attrs) return super().__new__(mcs, name, bases, attrs)
class Component(metaclass=SimplifiedInterfaceMediaDefiningClass): class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
def __init__(self, component_name): def __init__(self, component_name):
@ -73,45 +74,54 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
@staticmethod @staticmethod
def is_slot_node(node): def is_slot_node(node):
return node.token.token_type == TokenType.BLOCK and node.token.split_contents()[0] == "slot" return (isinstance(node, Node)
and node.token.token_type == TokenType.BLOCK
and node.token.split_contents()[0] == "slot")
@lru_cache(maxsize=TEMPLATE_CACHE_SIZE) @lru_cache(maxsize=TEMPLATE_CACHE_SIZE)
def compile_instance_template(self, template_name): def get_processed_template(self, template_name):
"""Use component's base template and the slots used for this instance to compile """Retrieve the requested template and add a link to this component to each SlotNode in the template."""
a unified template for this instance."""
component_template = get_template(template_name) source_template = get_template(template_name)
slots_in_template = Component.slots_in_template(component_template)
defined_slot_names = set(slots_in_template.keys()) # The template may be shared with another component (e.g., due to caching). To ensure that each
filled_slot_names = set(self.slots.keys()) # SlotNode is unique between components, we have to copy the nodes in the template nodelist and
unexpected_slots = filled_slot_names - defined_slot_names # any contained nodelists.
if unexpected_slots: component_template = copy(source_template.template)
cloned_nodelist = [duplicate_node(node) for node in component_template.nodelist]
component_template.nodelist = NodeList(cloned_nodelist)
# Traverse template nodes and descendants, and give each slot node a reference to this component.
visited_nodes = set()
nodes_to_visit = list(component_template.nodelist)
slots_seen = set()
while nodes_to_visit:
current_node = nodes_to_visit.pop()
if current_node in visited_nodes:
continue
visited_nodes.add(current_node)
for nodelist_name in current_node.child_nodelists:
nodes_to_visit.extend(getattr(current_node, nodelist_name, []))
if self.is_slot_node(current_node):
slots_seen.add(current_node.name)
current_node.parent_component = self
# Check and warn for unknown slots
if settings.DEBUG: if settings.DEBUG:
filled_slot_names = set(self.slots.keys())
unused_slots = filled_slot_names - slots_seen
if unused_slots:
warnings.warn( warnings.warn(
"Component {} was provided with unexpected slots: {}".format( "Component {} was provided with slots that were not used in a template: {}".format(
self._component_name, unexpected_slots self._component_name, unused_slots
) )
) )
for unexpected_slot in unexpected_slots:
del self.slots[unexpected_slot]
combined_slots = dict(slots_in_template, **self.slots) return component_template
if combined_slots:
# Replace slot nodes with their nodelists, then combine into a single, flat nodelist
node_iterator = ([node] if not Component.is_slot_node(node) else combined_slots[node.name]
for node in component_template.template.nodelist)
instance_template = copy(component_template.template)
instance_template.nodelist = NodeList(chain.from_iterable(node_iterator))
else:
instance_template = component_template.template
return instance_template
def render(self, context): def render(self, context):
template_name = self.template(context) template_name = self.template(context)
instance_template = self.compile_instance_template(template_name) instance_template = self.get_processed_template(template_name)
return instance_template.render(context) return instance_template.render(context)
class Media: class Media:
@ -122,6 +132,25 @@ class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
# This variable represents the global component registry # This variable represents the global component registry
registry = ComponentRegistry() registry = ComponentRegistry()
def duplicate_node(source_node):
"""Perform a shallow copy of source_node and then recursively copy over each of source_node's nodelists.
If a nodelist is a dynamic property that cannot be set, fall back to a deepcopy of source_node."""
try:
clone = copy(source_node)
for nodelist_name in source_node.child_nodelists:
nodelist = getattr(source_node, nodelist_name, NodeList())
nodelist_contents = [duplicate_node(n) for n in nodelist]
setattr(clone, nodelist_name, type(nodelist)(nodelist_contents))
return clone
except AttributeError:
# AttributeError is raised if an attribute cannot be set (e.g., IfNode's nodelist,
# which is a read-only property).
return deepcopy(source_node)
def register(name): def register(name):
"""Class decorator to register a component. """Class decorator to register a component.
@ -133,6 +162,7 @@ def register(name):
... ...
""" """
def decorator(component): def decorator(component):
registry.register(name=name, component=component) registry.register(name=name, component=component)
return component return component

View file

@ -11,7 +11,7 @@ from django_components.middleware import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDEN
register = template.Library() register = template.Library()
COMPONENT_CONTEXT_KEY = "component_context" SLOT_CONTEXT_KEY = "__slot_context"
def get_components_from_registry(registry): def get_components_from_registry(registry):
@ -79,14 +79,28 @@ def do_component(parser, token):
class SlotNode(Node): class SlotNode(Node):
def __init__(self, name, nodelist, component=None): def __init__(self, name, nodelist, component=None):
self.name, self.nodelist, self.component = name, nodelist, component self.name, self.nodelist, self.component = name, nodelist, component
self.component = None
self.parent_component = None
self.context = None
def __repr__(self): def __repr__(self):
return "<Slot Node: %s. Contents: %r>" % (self.name, self.nodelist) return "<Slot Node: %s. Contents: %r>" % (self.name, self.nodelist)
def render(self, context): def render(self, context):
# This method should only be called if a slot tag is used outside of a component. # Thread safety: storing the context as a property of the cloned SlotNode without using
assert self.component is None # the render_context facility should be thread-safe, since each cloned_node
return self.nodelist.render(context) # is only used for a single render.
cloned_node = SlotNode(self.name, self.nodelist, self.component)
cloned_node.parent_component = self.parent_component
cloned_node.context = context
assert not NodeList(), "Logic in SlotNode.render method assumes that empty nodelists are falsy."
with context.update({'slot': cloned_node}):
return cloned_node.parent_component.slots.get(cloned_node.name, cloned_node.nodelist).render(context)
def super(self):
"""Render default slot content."""
return mark_safe(self.nodelist.render(self.context))
@register.tag("slot") @register.tag("slot")
@ -103,19 +117,24 @@ def do_slot(parser, token, component=None):
class ComponentNode(Node): class ComponentNode(Node):
class InvalidSlot:
def super(self):
raise TemplateSyntaxError('slot.super may only be called within a {% slot %}/{% endslot %} block.')
def __init__(self, component, context_args, context_kwargs, slots=None, isolated_context=False): def __init__(self, component, context_args, context_kwargs, slots=None, isolated_context=False):
self.context_args = context_args or [] self.context_args = context_args or []
self.context_kwargs = context_kwargs or {} self.context_kwargs = context_kwargs or {}
self.component, self.isolated_context = component, isolated_context self.component, self.isolated_context = component, isolated_context
slot_dict = defaultdict(NodeList)
if slots: # Group slot notes by name and concatenate their nodelists
for slot in slots: self.component.slots = defaultdict(NodeList)
slot_dict[slot.name].extend(slot.nodelist) for slot in slots or []:
self.component.slots = slot_dict self.component.slots[slot.name].extend(slot.nodelist)
self.should_render_dependencies = is_dependency_middleware_active() self.should_render_dependencies = is_dependency_middleware_active()
def __repr__(self): def __repr__(self):
return "<Component Node: %s. Contents: %r>" % (self.component, self.component.instance_template.nodelist) return "<Component Node: %s. Contents: %r>" % (self.component,
getattr(self.component.instance_template, 'nodelist', None))
def render(self, context): def render(self, context):
self.component.outer_context = context.flatten() self.component.outer_context = context.flatten()
@ -123,9 +142,7 @@ class ComponentNode(Node):
# Resolve FilterExpressions and Variables that were passed as args to the component, then call component's # 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 # context method to get values to insert into the context
resolved_context_args = [safe_resolve(arg, context) for arg in self.context_args] resolved_context_args = [safe_resolve(arg, context) for arg in self.context_args]
resolved_context_kwargs = { resolved_context_kwargs = {key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()}
key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()
}
component_context = self.component.context(*resolved_context_args, **resolved_context_kwargs) component_context = self.component.context(*resolved_context_args, **resolved_context_kwargs)
# Create a fresh context if requested # Create a fresh context if requested
@ -133,6 +150,7 @@ class ComponentNode(Node):
context = context.new() context = context.new()
with context.update(component_context): with context.update(component_context):
context.render_context[SLOT_CONTEXT_KEY] = {}
rendered_component = self.component.render(context) rendered_component = self.component.render(context)
if self.should_render_dependencies: if self.should_render_dependencies:
return f'<!-- _RENDERED {self.component._component_name} -->' + rendered_component return f'<!-- _RENDERED {self.component._component_name} -->' + rendered_component
@ -158,31 +176,36 @@ def do_component_block(parser, token):
bits = token.split_contents() bits = token.split_contents()
bits, isolated_context = check_for_isolated_context_keyword(bits) bits, isolated_context = check_for_isolated_context_keyword(bits)
tag_name, token = next_block_token(parser)
component, context_args, context_kwargs = parse_component_with_args(parser, bits, 'component_block') component, context_args, context_kwargs = parse_component_with_args(parser, bits, 'component_block')
slots_filled = NodeList() return ComponentNode(component, context_args, context_kwargs,
while tag_name != "endcomponent_block": slots=[do_slot(parser, slot_token, component=component)
if tag_name == "slot": for slot_token in slot_tokens(parser)],
slots_filled += do_slot(parser, token, component=component)
tag_name, token = next_block_token(parser)
return ComponentNode(component, context_args, context_kwargs, slots=slots_filled,
isolated_context=isolated_context) isolated_context=isolated_context)
def next_block_token(parser): def slot_tokens(parser):
"""Return tag and token for next block token. """Yield each 'slot' token appearing before the next 'endcomponent_block' token.
Raises IndexError if there are not more block tokens in the remainder of the template.""" Raises TemplateSyntaxError if there are other content tokens or if there is no endcomponent_block token."""
def is_whitespace(token):
return token.token_type == TokenType.TEXT and not token.contents.strip()
def is_block_tag(token, /, name):
return token.token_type == TokenType.BLOCK and token.split_contents()[0] == name
while True: while True:
try:
token = parser.next_token() token = parser.next_token()
if token.token_type != TokenType.BLOCK: except IndexError:
continue raise TemplateSyntaxError('Unclosed component_block tag')
if is_block_tag(token, name='endcomponent_block'):
tag_name = token.split_contents()[0] return
return tag_name, token elif is_block_tag(token, name='slot'):
yield token
elif not is_whitespace(token) and token.token_type != TokenType.COMMENT:
raise TemplateSyntaxError(f'Content tokens in component blocks must be inside of slot tags: {token}')
def check_for_isolated_context_keyword(bits): def check_for_isolated_context_keyword(bits):

View file

@ -0,0 +1,6 @@
{% load component_tags %}
{% if branch == 'a' %}
<p id="a">{% slot 'a' %}Default A{% endslot %}</p>
{% elif branch == 'b' %}
<p id="b">{% slot 'b' %}Default B{% endslot %}</p>
{% endif %}

View file

@ -0,0 +1,4 @@
{% load component_tags %}
{% slot 'outer' %}
<div id="outer">{% slot 'inner' %}Default{% endslot %}</div>
{% endslot %}

View file

@ -1,11 +1,11 @@
from textwrap import dedent from textwrap import dedent
from django.template import Context from django.template import Context, Template
from .django_test_setup import * # NOQA from .django_test_setup import * # NOQA
from .testutils import Django30CompatibleSimpleTestCase as SimpleTestCase
from django_components import component from django_components import component
from .testutils import Django30CompatibleSimpleTestCase as SimpleTestCase
class ComponentTest(SimpleTestCase): class ComponentTest(SimpleTestCase):
@ -98,6 +98,8 @@ class ComponentTest(SimpleTestCase):
""") """)
) )
class ComponentMediaTests(SimpleTestCase):
def test_component_media_with_strings(self): def test_component_media_with_strings(self):
class SimpleComponent(component.Component): class SimpleComponent(component.Component):
class Media: class Media:
@ -164,3 +166,52 @@ class ComponentTest(SimpleTestCase):
<script src="path/to/script.js"></script> <script src="path/to/script.js"></script>
""") """)
) )
class ComponentIsolationTests(SimpleTestCase):
def setUp(self):
class SlottedComponent(component.Component):
def template(self, context):
return "slotted_template.html"
component.registry.register('test', SlottedComponent)
def test_instances_of_component_do_not_share_slots(self):
template = Template(
"""
{% load component_tags %}
{% component_block "test" %}
{% slot "header" %}Override header{% endslot %}
{% endcomponent_block %}
{% component_block "test" %}
{% slot "main" %}Override main{% endslot %}
{% endcomponent_block %}
{% component_block "test" %}
{% slot "footer" %}Override footer{% endslot %}
{% endcomponent_block %}
"""
)
template.render(Context({}))
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<custom-template>
<header>Override header</header>
<main>Default main</main>
<footer>Default footer</footer>
</custom-template>
<custom-template>
<header>Default header</header>
<main>Override main</main>
<footer>Default footer</footer>
</custom-template>
<custom-template>
<header>Default header</header>
<main>Default main</main>
<footer>Override footer</footer>
</custom-template>
"""
)

View file

@ -1,6 +1,6 @@
from textwrap import dedent from textwrap import dedent
from django.template import Context, Template from django.template import Context, Template, TemplateSyntaxError
from .django_test_setup import * # NOQA from .django_test_setup import * # NOQA
from django_components import component from django_components import component
@ -356,3 +356,281 @@ class TemplateInstrumentationTest(SimpleTestCase):
templates_used = self.templates_used_to_render(template) templates_used = self.templates_used_to_render(template)
self.assertIn('slotted_template.html', templates_used) self.assertIn('slotted_template.html', templates_used)
self.assertIn('simple_template.html', templates_used) self.assertIn('simple_template.html', templates_used)
class NestedSlotTests(SimpleTestCase):
class NestedComponent(component.Component):
def context(self):
return {}
def template(self, context):
return "nested_slot_template.html"
@classmethod
def setUpClass(cls):
super().setUpClass()
component.registry.clear()
component.registry.register('test', cls.NestedComponent)
@classmethod
def tearDownClass(cls):
super().tearDownClass()
component.registry.clear()
def test_default_slots_render_correctly(self):
template = Template(
"""
{% load component_tags %}
{% component_block 'test' %}{% endcomponent_block %}
"""
)
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, '<div id="outer">Default</div>')
def test_inner_slot_overriden(self):
template = Template(
"""
{% load component_tags %}
{% component_block 'test' %}{% slot 'inner' %}Override{% endslot %}{% endcomponent_block %}
"""
)
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, '<div id="outer">Override</div>')
def test_outer_slot_overriden(self):
template = Template(
"""
{% load component_tags %}
{% component_block 'test' %}{% slot 'outer' %}<p>Override</p>{% endslot %}{% endcomponent_block %}
"""
)
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, '<p>Override</p>')
def test_both_overriden_and_inner_removed(self):
template = Template(
"""
{% load component_tags %}
{% component_block 'test' %}
{% slot 'outer' %}<p>Override</p>{% endslot %}
{% slot 'inner' %}<p>Will not appear</p>{% endslot %}
{% endcomponent_block %}
"""
)
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, '<p>Override</p>')
class ConditionalSlotTests(SimpleTestCase):
class ConditionalComponent(component.Component):
def context(self, branch=None):
return {'branch': branch}
def template(self, context):
return "conditional_template.html"
@classmethod
def setUpClass(cls):
super().setUpClass()
component.registry.clear()
component.registry.register('test', cls.ConditionalComponent)
@classmethod
def tearDownClass(cls):
super().tearDownClass()
component.registry.clear()
def test_no_content_if_branches_are_false(self):
template = Template(
"""
{% load component_tags %}
{% component_block 'test' %}
{% slot 'a' %}Override A{% endslot %}
{% slot 'b' %}Override B{% endslot %}
{% endcomponent_block %}
"""
)
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, '')
def test_default_content_if_no_slots(self):
template = Template(
"""
{% load component_tags %}
{% component 'test' branch='a' %}
{% component 'test' branch='b' %}
"""
)
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, '<p id="a">Default A</p><p id="b">Default B</p>')
def test_one_slot_overridden(self):
template = Template(
"""
{% load component_tags %}
{% component_block 'test' branch='a' %}
{% slot 'b' %}Override B{% endslot %}
{% endcomponent_block %}
{% component_block 'test' branch='b' %}
{% slot 'b' %}Override B{% endslot %}
{% endcomponent_block %}
"""
)
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, '<p id="a">Default A</p><p id="b">Override B</p>')
def test_both_slots_overridden(self):
template = Template(
"""
{% load component_tags %}
{% component_block 'test' branch='a' %}
{% slot 'a' %}Override A{% endslot %}
{% slot 'b' %}Override B{% endslot %}
{% endcomponent_block %}
{% component_block 'test' branch='b' %}
{% slot 'a' %}Override A{% endslot %}
{% slot 'b' %}Override B{% endslot %}
{% endcomponent_block %}
"""
)
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, '<p id="a">Override A</p><p id="b">Override B</p>')
class SlotSuperTests(SimpleTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
component.registry.clear()
component.registry.register('test', SlottedComponent)
@classmethod
def tearDownClass(cls):
super().tearDownClass()
component.registry.clear()
def test_basic_super_functionality(self):
template = Template(
"""
{% load component_tags %}
{% component_block "test" %}
{% slot "header" %}Before: {{ slot.super }}{% endslot %}
{% slot "main" %}{{ slot.super }}{% endslot %}
{% slot "footer" %}{{ slot.super }}, after{% endslot %}
{% endcomponent_block %}
"""
)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<custom-template>
<header>Before: Default header</header>
<main>Default main</main>
<footer>Default footer, after</footer>
</custom-template>
""",
)
def test_multiple_super_calls(self):
template = Template(
"""
{% load component_tags %}
{% component_block "test" %}
{% slot "header" %}First: {{ slot.super }}; Second: {{ slot.super }}{% endslot %}
{% endcomponent_block %}
"""
)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<custom-template>
<header>First: Default header; Second: Default header</header>
<main>Default main</main>
<footer>Default footer</footer>
</custom-template>
""",
)
def test_super_under_if_node(self):
template = Template(
"""
{% load component_tags %}
{% component_block "test" %}
{% slot "header" %}
{% for i in range %}
{% if forloop.first %}First {{slot.super}}
{% else %}Later {{ slot.super }}
{% endif %}
{%endfor %}
{% endslot %}
{% endcomponent_block %}
"""
)
rendered = template.render(Context({'range': range(3)}))
self.assertHTMLEqual(
rendered,
"""
<custom-template>
<header>First Default header Later Default header Later Default header</header>
<main>Default main</main>
<footer>Default footer</footer>
</custom-template>
""",
)
class TemplateSyntaxErrorTests(SimpleTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
component.registry.register('test', SlottedComponent)
def test_variable_outside_slot_tag_is_error(self):
with self.assertRaises(TemplateSyntaxError):
Template(
"""
{% load component_tags %}
{% component_block "test" %}
{{ slot.super }}
{% endcomponent_block %}
"""
)
def test_text_outside_slot_tag_is_error(self):
with self.assertRaises(TemplateSyntaxError):
Template(
"""
{% load component_tags %}
{% component_block "test" %}
Text
{% endcomponent_block %}
"""
)
def test_nonslot_block_outside_slot_tag_is_error(self):
with self.assertRaises(TemplateSyntaxError):
Template(
"""
{% load component_tags %}
{% component_block "test" %}
{% if True %}
{% slot "header" %}{% endslot %}
{% endif %}
{% endcomponent_block %}
"""
)
def test_unclosed_component_block_is_error(self):
with self.assertRaises(TemplateSyntaxError):
Template(
"""
{% load component_tags %}
{% component_block "test" %}
{% slot "header" %}{% endslot %}
"""
)