Add slots to components that can be filled when using component.

This commit is contained in:
Emil Stenström 2020-06-06 09:11:27 +02:00
parent f6d2c5e003
commit d1405dda13
6 changed files with 316 additions and 13 deletions

View file

@ -2,16 +2,19 @@
<a href="https://travis-ci.org/EmilStenstrom/django-components"><img align="right" src="https://travis-ci.org/EmilStenstrom/django-components.svg?branch=master"></a>
A way to create simple reusable template components in Django.
# Compatiblity
It lets you create "template components", that contains both the template, the Javascript and the CSS needed to generate the front end code you need for a modern app. Components look like this:
| Python version | Django version |
|----------------|--------------------------|
| 2.7 | 1.11 |
| 3.4 | 1.11, 2.0 |
| 3.5 | 1.11, 2.0, 2.1, 2.2 |
| 3.6 | 1.11, 2.0, 2.1, 2.2, 3.0 |
| 3.7 | 1.11, 2.0, 2.1, 2.2, 3.0 |
| 3.8 | 1.11, 2.0, 2.1, 2.2, 3.0 |
```htmldjango
{% component name="calendar" date="2015-06-19" %}
```
And this is what gets rendered (plus the CSS and Javascript you've specified):
```html
<div class="calendar-component">Today's date is <span>2015-06-19</span></div>
```
Read on to learn about the details!
# Installation
@ -49,6 +52,17 @@ TEMPLATES = [
]
```
# Compatiblity
| Python version | Django version |
|----------------|--------------------------|
| 2.7 | 1.11 |
| 3.4 | 1.11, 2.0 |
| 3.5 | 1.11, 2.0, 2.1, 2.2 |
| 3.6 | 1.11, 2.0, 2.1, 2.2, 3.0 |
| 3.7 | 1.11, 2.0, 2.1, 2.2, 3.0 |
| 3.8 | 1.11, 2.0, 2.1, 2.2, 3.0 |
# Create your first component
A component in django-components is the combination of four things: CSS, Javascript, a Django template, and some Python code to put them all together.
@ -136,6 +150,31 @@ The output from the above template will be:
This makes it possible to organize your front-end around reusable components. Instead of relying on template tags and keeping your CSS and Javascript in the static directory.
# Using slots in templates
Components support something called slots. They work a lot like Django blocks, but only inside components you define. Let's update our calendar component to support more customization, by updating our calendar.html template:
```htmldjango
<div class="calendar-component">
<div class="header">
{% slot "header" %}Calendar header{% endslot %}
</div>
<div class="body">
{% slot "body" %}Calendar body{% endslot %}
</div>
</div>
```
When using the component, you specify what slots you want to fill and where you want to use the defaults from the template. It looks like this:
```
{% component_block %}
{% slot "body" %}Today's date is <span>{{ date }}</span>{% endslot %}
{% endcomponent_block %}
```
Since to header block is unspecified, it's taken from the base template.
# Running the tests
To quickly run the tests install the local dependencies by running

View file

@ -1,5 +1,9 @@
from inspect import getfullargspec
from django.forms.widgets import MediaDefiningClass
from django.template.loader import render_to_string
from django.template import Context
from django.template.base import NodeList, TokenType, TextNode
from django.template.loader import get_template
from six import with_metaclass
# Allow "component.AlreadyRegistered" instead of having to import these everywhere
@ -16,9 +20,44 @@ class Component(with_metaclass(MediaDefiningClass)):
def render_dependencies(self):
return self.media.render()
def render(self, *args, **kwargs):
context = self.context(*args, **kwargs)
return render_to_string(self.template(context), context)
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
def render(self, slots_filled=None, *args, **kwargs):
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)
else:
nodelist.append(node)
return nodelist.render(Context(context))
return template.render(context)
class Media:
css = {}

View file

@ -1,10 +1,15 @@
from django import template
from django.utils.safestring import mark_safe
from django.template.base import Node, NodeList, TokenType, TemplateSyntaxError, token_kwargs
from django.template.library import parse_bits
from django_components.component import registry
register = template.Library()
COMPONENT_CONTEXT_KEY = "component_context"
@register.simple_tag(name="component_dependencies")
def component_dependencies_tag():
unique_component_classes = set(registry.all().values())
@ -16,8 +21,123 @@ def component_dependencies_tag():
return mark_safe("\n".join(out))
@register.simple_tag(name="component")
def component_tag(name, *args, **kwargs):
component_class = registry.get(name)
component = component_class()
return component.render(*args, **kwargs)
class SlotNode(Node):
def __init__(self, name, nodelist, component=None):
self.name, self.nodelist, self.component = name, nodelist, component
def __repr__(self):
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 ''
@register.tag("slot")
def do_slot(parser, token, component=None):
bits = token.split_contents()
if len(bits) != 2:
raise TemplateSyntaxError("'%s' tag takes only one argument" % bits[0])
slot_name = bits[1].strip('"')
nodelist = parser.parse(parse_until=["endslot"])
parser.delete_first_token()
return SlotNode(slot_name, nodelist, component=component)
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 __repr__(self):
return "<Component Node: %s. Contents: %r>" % (self.component, self.slots)
def render(self, context):
extra_context = {
key: filter_expression.resolve(context)
for key, filter_expression in self.extra_context.items()
}
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]
return self.component.render(slots_filled=slots_filled, **context.flatten())
return self.component.render()
@register.tag("component_block")
def do_component(parser, token):
"""
{% component_block "name" variable="value" variable2="value2" ... %}
"""
bits = token.split_contents()
tag_args, tag_kwargs = parse_bits(
parser=parser,
bits=bits,
params=["tag_name", "component_name"],
varargs=None,
varkw=[],
defaults=None,
kwonly=[],
kwonly_defaults=None,
takes_context=False,
name="component_block"
)
print(tag_args)
tag_name = tag_args.pop(0)
# assert False, tag_args[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()
extra_context = {}
if len(bits) > 2:
extra_context = component.context(**token_kwargs(bits[2:], parser))
slots_filled = NodeList()
tag_name = bits[0]
while tag_name != "endcomponent_block":
token = parser.next_token()
if token.token_type != TokenType.BLOCK:
continue
tag_name = token.split_contents()[0]
if tag_name == "slot":
slots_filled += do_slot(parser, token, component=component)
elif tag_name == "endcomponent_block":
break
return ComponentNode(component, extra_context, slots_filled)

View file

@ -0,0 +1,6 @@
{% load component_tags %}
<custom-template>
<header>{% slot header %}Default header{% endslot %}</header>
<main>{% slot main %}Default main{% endslot %}</main>
<footer>{% slot footer %}Default footer{% endslot %}</footer>
</custom-template>

View file

@ -0,0 +1 @@
<custom-template></custom-template>

View file

@ -22,10 +22,27 @@ class SimpleComponent(component.Component):
css = {"all": ["style.css"]}
js = ["script.js"]
class IffedComponent(SimpleComponent):
def template(self, context):
return "iffed_template.html"
class SlottedComponent(component.Component):
def template(self, context):
return "slotted_template.html"
class SlottedComponentNoSlots(component.Component):
def template(self, context):
return "slotted_template_no_slots.html"
class SlottedComponentWithContext(component.Component):
def context(self, variable):
return {"variable": variable}
def template(self, context):
return "slotted_template.html"
class ComponentTemplateTagTest(SimpleTestCase):
def setUp(self):
# NOTE: component.registry is global, so need to clear before each test
@ -75,3 +92,84 @@ class ComponentTemplateTagTest(SimpleTestCase):
<link href="style.css" type="text/css" media="all" rel="stylesheet">
<script type="text/javascript" src="script.js"></script>
""").strip())
class ComponentSlottedTemplateTagTest(SimpleTestCase):
def setUp(self):
# NOTE: component.registry is global, so need to clear before each test
component.registry.clear()
def test_slotted_template_basic(self):
component.registry.register(name="test1", component=SlottedComponent)
component.registry.register(name="test2", component=SimpleComponent)
template = Template("""
{% load component_tags %}
{% component_block "test1" %}
{% slot "header" %}
Custom header
{% endslot %}
{% slot "main" %}
{% component "test2" variable="variable" %}
{% endslot %}
{% endcomponent_block %}
""")
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, """
<custom-template>
<header>Custom header</header>
<main>Variable: <strong>variable</strong></main>
<footer>Default footer</footer>
</custom-template>
""")
def test_slotted_template_with_context_var(self):
component.registry.register(name="test1", component=SlottedComponentWithContext)
template = Template("""
{% load component_tags %}
{% with my_first_variable="test123" %}
{% component_block "test1" variable="test456" %}
{% slot "main" %}
{{ my_first_variable }} - {{ variable }}
{% endslot %}
{% slot "footer" %}
{{ my_second_variable }}
{% endslot %}
{% endcomponent_block %}
{% endwith %}
""")
rendered = template.render(Context({"my_second_variable": "test321"}))
self.assertHTMLEqual(rendered, """
<custom-template>
<header>Default header</header>
<main>test123 - test456</main>
<footer>test321</footer>
</custom-template>
""")
def test_slotted_template_no_slots_filled(self):
component.registry.register(name="test", component=SlottedComponent)
template = Template('{% load component_tags %}{% component_block "test" %}{% endcomponent_block %}')
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, """
<custom-template>
<header>Default header</header>
<main>Default main</main>
<footer>Default footer</footer>
</custom-template>
""")
def test_slotted_template_without_slots(self):
component.registry.register(name="test", component=SlottedComponentNoSlots)
template = Template("""
{% load component_tags %}
{% component_block "test" %}{% endcomponent_block %}
""")
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, "<custom-template></custom-template>")