Pass context into component tags by default, and let components disable with "only" (#20)

Co-authored-by: rbeard0330 <@dul2k3BKW6m>
This commit is contained in:
rbeard0330 2021-01-25 06:31:54 -05:00 committed by GitHub
parent a99c8d7ad0
commit 2633c3f08f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 229 additions and 99 deletions

View file

@ -1,5 +1,9 @@
import warnings
from itertools import chain
from django.conf import settings
from django.forms.widgets import MediaDefiningClass
from django.template.base import NodeList, TextNode
from django.template.base import NodeList
from django.template.loader import get_template
from django.utils.safestring import mark_safe
from six import with_metaclass
@ -20,6 +24,10 @@ except ImportError:
class Component(with_metaclass(MediaDefiningClass)):
def __init__(self, component_name):
self.__component_name = component_name
def context(self):
return {}
@ -41,32 +49,36 @@ class Component(with_metaclass(MediaDefiningClass)):
return mark_safe("\n".join(self.media.render_js()))
def slots_in_template(self, template):
return NodeList(node for node in template.template.nodelist if is_slot_node(node))
@staticmethod
def slots_in_template(template):
return {node.name: node.nodelist for node in template.template.nodelist if is_slot_node(node)}
def render(self, context, slots_filled=None):
slots_filled = slots_filled or {}
template = get_template(self.template(context))
slots_in_template = self.slots_in_template(template)
# If there are no slots, then we can simply render the template
if not slots_in_template:
return template.template.render(context)
defined_slot_names = set(slots_in_template.keys())
filled_slot_names = set(slots_filled.keys())
unexpected_slots = filled_slot_names - defined_slot_names
if unexpected_slots:
if settings.DEBUG:
warnings.warn(
"Component {} was provided with unexpected slots: {}".format(
self.__component_name, unexpected_slots
)
)
for unexpected_slot in unexpected_slots:
del slots_filled[unexpected_slot]
# 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.extend(node.nodelist)
else:
nodelist.append(node)
combined_slots = dict(slots_in_template, **slots_filled)
# Replace slot nodes with their nodelists, then combine into a single, flat nodelist
node_iterator = ([node] if not is_slot_node(node) else combined_slots[node.name]
for node in template.template.nodelist)
flattened_nodelist = NodeList(chain.from_iterable(node_iterator))
return nodelist.render(context)
return flattened_nodelist.render(context)
class Media:
css = {}

View file

@ -1,6 +1,8 @@
from collections import defaultdict
import django
from django import template
from django.template.base import Node, NodeList, TemplateSyntaxError, token_kwargs
from django.template.base import Node, NodeList, TemplateSyntaxError
from django.template.library import parse_bits
from django.utils.safestring import mark_safe
@ -21,7 +23,6 @@ except ImportError:
# Django < 2.0 compatibility
if django.VERSION > (2, 0):
PARSE_BITS_DEFAULTS = {
"varargs": None,
"varkw": [],
"defaults": None,
"kwonly": [],
@ -29,7 +30,6 @@ if django.VERSION > (2, 0):
}
else:
PARSE_BITS_DEFAULTS = {
"varargs": None,
"varkw": [],
"defaults": None,
}
@ -46,7 +46,7 @@ def get_components_from_registry(registry):
components = []
for component_class in unique_component_classes:
components.append(component_class())
components.append(component_class(component_class.__name__))
return components
@ -84,12 +84,12 @@ def component_js_dependencies_tag():
return mark_safe("\n".join(rendered_dependencies))
@register.simple_tag(name="component")
def component_tag(name, *args, **kwargs):
component_class = registry.get(name)
component = component_class()
context = template.Context(component.context(*args, **kwargs))
return component.render(context)
@register.tag(name='component')
def do_component(parser, token):
bits = token.split_contents()
bits, isolated_context = check_for_isolated_context_keyword(bits)
component, context_args, context_kwargs = parse_component_with_args(parser, bits, 'component')
return ComponentNode(component, context_args, context_kwargs, isolated_context=isolated_context)
class SlotNode(Node):
@ -100,20 +100,9 @@ class SlotNode(Node):
return "<Slot Node: %s. Contents: %r>" % (self.name, self.nodelist)
def render(self, context):
if COMPONENT_CONTEXT_KEY not in context.render_context:
context.render_context[COMPONENT_CONTEXT_KEY] = {}
if self.component not in context.render_context[COMPONENT_CONTEXT_KEY]:
context.render_context[COMPONENT_CONTEXT_KEY][self.component] = {}
rendered_slot = self.nodelist.render(context)
if self.component:
context.render_context[COMPONENT_CONTEXT_KEY][self.component][
self.name
] = rendered_slot
return ""
# This method should only be called if a slot tag is used outside of a component.
assert self.component is None
return self.nodelist.render(context)
@register.tag("slot")
@ -130,79 +119,133 @@ def do_slot(parser, token, component=None):
class ComponentNode(Node):
def __init__(self, component, context_kwargs, slots):
def __init__(self, component, context_args, context_kwargs, slots=None, isolated_context=False):
self.slots = defaultdict(NodeList)
for slot in slots or []:
self.slots[slot.name].extend(slot.nodelist)
self.context_args = context_args or []
self.context_kwargs = context_kwargs or {}
self.component, self.slots = component, slots
self.component, self.isolated_context = component, isolated_context
def __repr__(self):
return "<Component Node: %s. Contents: %r>" % (self.component, self.slots)
def render(self, context):
self.component.outer_context = context.flatten()
# 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_args = [safe_resolve(arg, context) for arg in self.context_args]
resolved_context_kwargs = {
key: context_item.resolve(context) if hasattr(context_item, 'resolve') else context_item
for key, context_item in self.context_kwargs.items()
key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()
}
extra_context = self.component.context(**resolved_context_kwargs)
component_context = self.component.context(*resolved_context_args, **resolved_context_kwargs)
with context.update(extra_context):
self.slots.render(context)
slots_filled = context.render_context.get(COMPONENT_CONTEXT_KEY, {}).get(self.component, {})
return self.component.render(context, slots_filled=slots_filled)
# Create a fresh context if requested
if self.isolated_context:
context = context.new()
with context.update(component_context):
return self.component.render(context, slots_filled=self.slots)
@register.tag("component_block")
def do_component(parser, token):
def do_component_block(parser, token):
"""
{% component_block "name" variable="value" variable2="value2" ... %}
To give the component access to the template context:
{% component_block "name" positional_arg keyword_arg=value ... %}
To render the component in an isolated context:
{% component_block "name" positional_arg keyword_arg=value ... only %}
Positional and keyword arguments can be literals or template variables.
The component name must be a single- or double-quotes string and must
be either the first positional argument or, if there are no positional
arguments, passed as 'name'.
"""
bits = token.split_contents()
bits, isolated_context = check_for_isolated_context_keyword(bits)
tag_args, tag_kwargs = parse_bits(
parser=parser,
bits=bits,
params=["tag_name", "component_name"],
takes_context=False,
name="component_block",
**PARSE_BITS_DEFAULTS
)
tag_name = tag_args.pop(0)
if len(bits) < 2:
raise TemplateSyntaxError(
"Call the '%s' tag with a component name as the first parameter" % tag_name
)
component_name = bits[1]
if not component_name.startswith(('"', "'")) or not component_name.endswith(
('"', "'")
):
raise TemplateSyntaxError(
"Component name '%s' should be in quotes" % component_name
)
component_name = component_name.strip('"\'')
component_class = registry.get(component_name)
component = component_class()
context_kwargs = {}
if len(bits) > 2:
context_kwargs = token_kwargs(bits[2:], parser)
tag_name, token = next_block_token(parser)
component, context_args, context_kwargs = parse_component_with_args(parser, bits, 'component_block')
slots_filled = NodeList()
tag_name = bits[0]
while tag_name != "endcomponent_block":
if tag_name == "slot":
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)
def next_block_token(parser):
"""Return tag and token for next block token.
Raises IndexError if there are not more block tokens in the remainder of the template."""
while True:
token = parser.next_token()
if token.token_type != TokenType.BLOCK:
continue
tag_name = token.split_contents()[0]
return tag_name, token
if tag_name == "slot":
slots_filled += do_slot(parser, token, component=component)
elif tag_name == "endcomponent_block":
break
return ComponentNode(component, context_kwargs, slots_filled)
def check_for_isolated_context_keyword(bits):
"""Return True and strip the last word if token ends with 'only' keyword."""
if bits[-1] == 'only':
return bits[:-1], True
return bits, False
def parse_component_with_args(parser, bits, tag_name):
tag_args, tag_kwargs = parse_bits(
parser=parser,
bits=bits,
params=["tag_name", "name"],
takes_context=False,
name=tag_name,
varargs=True,
**PARSE_BITS_DEFAULTS
)
assert tag_name == tag_args[0].token, "Internal error: Expected tag_name to be {}, but it was {}".format(
tag_name, tag_args[0].token)
if len(tag_args) > 1: # At least one position arg, so take the first as the component name
component_name = tag_args[1].token
context_args = tag_args[2:]
context_kwargs = tag_kwargs
else: # No positional args, so look for component name as keyword arg
try:
component_name = tag_kwargs.pop('name').token
context_args = []
context_kwargs = tag_kwargs
except IndexError:
raise TemplateSyntaxError(
"Call the '%s' tag with a component name as the first parameter" % tag_name
)
if not is_wrapped_in_quotes(component_name):
raise TemplateSyntaxError(
"Component name '%s' should be in quotes" % component_name
)
trimmed_component_name = component_name[1: -1]
component_class = registry.get(trimmed_component_name)
component = component_class(trimmed_component_name)
return component, context_args, context_kwargs
def safe_resolve(context_item, context):
"""Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
return context_item.resolve(context) if hasattr(context_item, 'resolve') else context_item
def is_wrapped_in_quotes(s):
return s.startswith(('"', "'")) and s[0] == s[-1]